diff --git a/.changeset/fix-unknown-tool-protocol-error.md b/.changeset/fix-unknown-tool-protocol-error.md new file mode 100644 index 000000000..baed50cf8 --- /dev/null +++ b/.changeset/fix-unknown-tool-protocol-error.md @@ -0,0 +1,15 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/server": minor +--- + +BREAKING: Fix error handling for unknown tools and resources per MCP spec + +**Tools:** Unknown or disabled tool calls now return JSON-RPC protocol errors with +code `-32602` (InvalidParams) instead of `CallToolResult` with `isError: true`. +Users who checked `result.isError` for unknown tools should catch rejected promises instead. + +**Resources:** Unknown resource reads now return error code `-32002` (ResourceNotFound) +instead of `-32602` (InvalidParams), per the MCP specification. + +Added `ProtocolErrorCode.ResourceNotFound` to the ProtocolErrorCode enum. diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 4e5f3d9b9..f2601c823 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -305,7 +305,7 @@ describe('createPrivateKeyJwtAuth', () => { const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( - /Invalid character/ + /cannot be part of a valid base64|Invalid character/ ); }); diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index f9e475340..8c3b7ea88 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -228,6 +228,7 @@ export enum ProtocolErrorCode { InternalError = -32_603, // MCP-specific error codes + ResourceNotFound = -32_002, UrlElicitationRequired = -32_042 } diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 316074e2d..7fc5da960 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -166,15 +166,17 @@ export class McpServer { ); this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { - try { - const tool = this._registeredTools[request.params.name]; - if (!tool) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); - } - if (!tool.enabled) { - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); - } + // Unknown/disabled tool is a protocol error per MCP spec. + // Check before try/catch so it propagates as a JSON-RPC error, not a CallToolResult with isError. + const tool = this._registeredTools[request.params.name]; + if (!tool) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); + } + if (!tool.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); + } + try { const isTaskRequest = !!request.params.task; const taskSupport = tool.execution?.taskSupport; const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); @@ -506,7 +508,7 @@ export class McpServer { } } - throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} not found`); + throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); }); this._resourceHandlersInitialized = true; diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 091e4ac21..0b1d6c27c 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1837,25 +1837,68 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'nonexistent-tool' - } - }, - CallToolResultSchema - ); + // Unknown tool should return a JSON-RPC protocol error per MCP spec. + await expect( + client.request( + { + method: 'tools/call', + params: { + name: 'nonexistent-tool' + } + }, + CallToolResultSchema + ) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('nonexistent-tool') + }); + }); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + /*** + * Test: ProtocolError for Disabled Tool + */ + test('should throw ProtocolError for disabled tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const tool = mcpServer.registerTool('test-tool', {}, async () => ({ + content: [ { type: 'text', - text: expect.stringContaining('Tool nonexistent-tool not found') + text: 'Test response' } - ]) - ); + ] + })); + + // Disable the tool + tool.disable(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Disabled tool should return a JSON-RPC protocol error per MCP spec. + await expect( + client.request( + { + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('disabled') + }); }); /*** @@ -2887,7 +2930,56 @@ describe('Zod v4', () => { }, ReadResourceResultSchema ) - ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); + ).rejects.toMatchObject({ + code: ProtocolErrorCode.ResourceNotFound, + message: expect.stringContaining('not found') + }); + }); + + /*** + * Test: ProtocolError for Disabled Resource + */ + test('should throw ProtocolError for disabled resource', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const resource = mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + // Disable the resource + resource.disable(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Disabled resource should return a JSON-RPC protocol error. + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource' + } + }, + ReadResourceResultSchema + ) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('disabled') + }); }); /*** @@ -3816,7 +3908,10 @@ describe('Zod v4', () => { }, GetPromptResultSchema ) - ).rejects.toThrow(/Prompt nonexistent-prompt not found/); + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('not found') + }); }); /***