Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/spec-method-invalid-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/sdk': patch
---

A request whose params fail the schema it was registered with (for example `prompts/get` without a `name`, `logging/setLevel` with an unknown level, or `tools/call` with a non-string `name`) is now answered with `-32602` Invalid params instead of `-32603` Internal error. The
message reads `Invalid params for <method>: …`.
11 changes: 8 additions & 3 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js';
import { AnySchema, AnyObjectSchema, SchemaOutput, getParseErrorMessage, safeParse } from '../server/zod-compat.js';
import {
CancelledNotificationSchema,
ClientCapabilities,
Expand Down Expand Up @@ -1459,8 +1459,13 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
this.assertRequestHandlerCapability(method);

this._requestHandlers.set(method, (request, extra) => {
const parsed = parseWithCompat(requestSchema, request) as SchemaOutput<T>;
return Promise.resolve(handler(parsed, extra));
const parsed = safeParse(requestSchema, request);
if (!parsed.success) {
// Params that fail the request schema are the caller's error, so answer
// -32602 Invalid params rather than letting the parse error become -32603.
throw new McpError(ErrorCode.InvalidParams, `Invalid params for ${method}: ${getParseErrorMessage(parsed.error)}`);
}
return Promise.resolve(handler(parsed.data, extra));
});
}

Expand Down
22 changes: 3 additions & 19 deletions test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
},
'protocol:error:invalid-params': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#responses',
behavior: 'A request with malformed params is answered with JSON-RPC error -32602 Invalid params.',
knownFailures: [
{
note: 'Protocol wraps schema-parse failures as -32603 (InternalError), not -32602 (InvalidParams) as required by JSON-RPC 2.0.'
}
]
behavior: 'A request with malformed params is answered with JSON-RPC error -32602 Invalid params.'
},
'protocol:error:method-not-found': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#responses',
Expand Down Expand Up @@ -748,13 +743,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
},
'logging:set-level:invalid-level': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#error-handling',
behavior: 'logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).',
knownFailures: [
{
test: 'mcpserver',
note: 'Protocol wraps schema-parse failures as -32603 (InternalError), not -32602 (InvalidParams) as required by JSON-RPC 2.0.'
}
]
behavior: 'logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).'
},
'logging:out-of-band:basic': {
transports: STATEFUL_TRANSPORTS,
Expand Down Expand Up @@ -1023,12 +1012,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
behavior:
'Form-mode requested schemas are flat objects with primitive-typed properties only; nested structures and arrays of objects are not used.',
transports: ['inMemory', 'stdio', 'streamableHttp'],
note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.',
knownFailures: [
{
note: 'The client rejects a nested requestedSchema before the user handler, but via the raw ZodError from parseWithCompat in Protocol.setRequestHandler (src/shared/protocol.ts:1428-1431), which _onrequest maps to -32603 InternalError (~line 823) instead of -32602 InvalidParams; the InvalidParams guard in src/client/index.ts (~363-368) is unreachable.'
}
]
note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.'
},

// Roots
Expand Down
3 changes: 2 additions & 1 deletion test/e2e/scenarios/logging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ verifies(
'logging:set-level:invalid-level',
async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, loggingServer, client);
// strictValidation off: the invalid level must actually reach the server for it to reject.
await using _ = await wire(transport, loggingServer, client, { strictValidation: false });

const tap = tapWire(client);

Expand Down
25 changes: 25 additions & 0 deletions test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import {
CallToolRequestSchema,
ClientCapabilities,
ErrorCode,
GetPromptRequestSchema,
JSONRPCMessage,
McpError,
RELATED_TASK_META_KEY,
RequestId,
ServerCapabilities,
SetLevelRequestSchema,
Task,
TaskCreationParams,
type Request,
Expand Down Expand Up @@ -197,6 +199,29 @@ describe('protocol tests', () => {
expect(onmessageMock).toHaveBeenCalled();
});

test.each([
{ schema: GetPromptRequestSchema, method: 'prompts/get', params: {} },
{ schema: SetLevelRequestSchema, method: 'logging/setLevel', params: { level: 'loud' } }
])('should answer $method with invalid params as InvalidParams without calling the handler', async ({ schema, method, params }) => {
const handler = vi.fn();
protocol.setRequestHandler(schema, handler);
await protocol.connect(transport);

transport.onmessage?.({ jsonrpc: '2.0', id: 1, method, params });
await new Promise(resolve => setTimeout(resolve, 10));

expect(handler).not.toHaveBeenCalled();
expect(sendSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: 1,
error: expect.objectContaining({
code: ErrorCode.InvalidParams,
message: expect.stringContaining(`Invalid params for ${method}:`)
})
})
);
});

describe('_meta preservation with onprogress', () => {
test('should preserve existing _meta when adding progressToken', async () => {
await protocol.connect(transport);
Expand Down
Loading