From a6dd2a953d3f40b7caedbc0072adf938833e6b5c Mon Sep 17 00:00:00 2001 From: Travis Bonnet Date: Thu, 19 Mar 2026 11:14:18 -0500 Subject: [PATCH] feat(server): add onInputValidationError callback to McpServerOptions Adds an optional `onInputValidationError` callback to `McpServerOptions` that fires before the validation error is returned to the client when a tool call fails input schema validation. This enables servers to add logging, metrics, or other observability for invalid tool calls without having to intercept responses downstream or skip inputSchema entirely. The callback receives the tool name, the arguments that were passed, and the individual validation issues from the schema parse. Fixes #1160 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/input-validation-error-callback.md | 5 + packages/server/src/server/mcp.ts | 37 ++++- test/integration/test/server/mcp.test.ts | 150 ++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 .changeset/input-validation-error-callback.md 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 */