Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/input-validation-error-callback.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 36 additions & 1 deletion packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

/**
* 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
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
150 changes: 150 additions & 0 deletions test/integration/test/server/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading