diff --git a/.changeset/input-validation-error-callback.md b/.changeset/input-validation-error-callback.md new file mode 100644 index 000000000..3cac8dce7 --- /dev/null +++ b/.changeset/input-validation-error-callback.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add `onInputValidationError` callback to `McpServerOptions`. When a tool call fails input schema validation, this callback fires before the error is returned to the client, enabling observability (logging, metrics) for invalid tool calls. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 05136f5b6..d3c53fecf 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -50,6 +50,32 @@ import { getCompleter, isCompletable } from './completable.js'; import type { ServerOptions } from './server.js'; import { Server } from './server.js'; +/** + * Callback invoked when tool input validation fails against the tool's inputSchema. + * Called before the validation error is returned to the client, allowing servers + * to add logging, metrics, or other observability for invalid tool calls. + */ +export type InputValidationErrorCallback = (error: { + /** The name of the tool that was called. */ + toolName: string; + /** The arguments that were passed to the tool. */ + arguments: unknown; + /** Individual validation issues from the schema parse. */ + issues: Array<{ message: string }>; +}) => void | Promise; + +/** + * Options for configuring an McpServer instance. + */ +export type McpServerOptions = ServerOptions & { + /** + * Optional callback invoked when a tool call fails input schema validation. + * This fires before the validation error is returned to the client, enabling + * observability (logging, metrics, etc.) for invalid tool calls. + */ + onInputValidationError?: InputValidationErrorCallback; +}; + /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. * For advanced usage (like sending notifications or setting custom request handlers), use the underlying @@ -76,9 +102,11 @@ export class McpServer { private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; private _experimental?: { tasks: ExperimentalMcpServerTasks }; + private _onInputValidationError?: InputValidationErrorCallback; - constructor(serverInfo: Implementation, options?: ServerOptions) { + constructor(serverInfo: Implementation, options?: McpServerOptions) { this.server = new Server(serverInfo, options); + this._onInputValidationError = options?.onInputValidationError; } /** @@ -258,6 +286,13 @@ export class McpServer { const parseResult = await parseSchemaAsync(tool.inputSchema, args ?? {}); if (!parseResult.success) { + if (this._onInputValidationError) { + await this._onInputValidationError({ + toolName, + arguments: args, + issues: parseResult.error.issues.map((i: { message: string }) => ({ message: i.message })) + }); + } const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); throw new ProtocolError( ProtocolErrorCode.InvalidParams, diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 416f05102..c11b3ea17 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1180,6 +1180,156 @@ describe('Zod v4', () => { ); }); + /*** + * Test: onInputValidationError callback + */ + test('should call onInputValidationError callback on validation failure', async () => { + const validationErrors: Array<{ toolName: string; arguments: unknown; issues: Array<{ message: string }> }> = []; + + const mcpServer = new McpServer( + { name: 'test server', version: '1.0' }, + { + onInputValidationError: error => { + validationErrors.push(error); + } + } + ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ + name: z.string(), + value: z.number() + }) + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'not a number' + } + } + }); + + expect(result.isError).toBe(true); + expect(validationErrors).toHaveLength(1); + expect(validationErrors[0]!.toolName).toBe('test'); + expect(validationErrors[0]!.arguments).toEqual({ name: 'test', value: 'not a number' }); + expect(validationErrors[0]!.issues.length).toBeGreaterThan(0); + expect(validationErrors[0]!.issues[0]!.message).toBeDefined(); + }); + + test('should not call onInputValidationError callback on successful validation', async () => { + const validationErrors: Array<{ toolName: string; arguments: unknown; issues: Array<{ message: string }> }> = []; + + const mcpServer = new McpServer( + { name: 'test server', version: '1.0' }, + { + onInputValidationError: error => { + validationErrors.push(error); + } + } + ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ + name: z.string() + }) + }, + async ({ name }) => ({ + content: [ + { + type: 'text', + text: `Hello, ${name}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { name: 'world' } + } + }); + + expect(result.isError).toBeUndefined(); + expect(validationErrors).toHaveLength(0); + }); + + test('should support async onInputValidationError callback', async () => { + let callbackCompleted = false; + + const mcpServer = new McpServer( + { name: 'test server', version: '1.0' }, + { + onInputValidationError: async _error => { + await new Promise(resolve => setTimeout(resolve, 10)); + callbackCompleted = true; + } + } + ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ + value: z.number() + }) + }, + async ({ value }) => ({ + content: [{ type: 'text', text: `${value}` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { value: 'not a number' } + } + }); + + expect(callbackCompleted).toBe(true); + }); + /*** * Test: Preventing Duplicate Tool Registration */