diff --git a/.changeset/fix-unknown-tool-protocol-error.md b/.changeset/fix-unknown-tool-protocol-error.md new file mode 100644 index 000000000..b6d703602 --- /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 `ErrorCode.ResourceNotFound` to the ErrorCode enum. diff --git a/packages/client/test/client/auth-extensions.test.ts b/packages/client/test/client/auth-extensions.test.ts index f7bde7f24..9f49cb1d6 100644 --- a/packages/client/test/client/auth-extensions.test.ts +++ b/packages/client/test/client/auth-extensions.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 35b04745d..53a3a5927 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -229,6 +229,7 @@ export enum ErrorCode { // SDK error codes ConnectionClosed = -32000, RequestTimeout = -32001, + ResourceNotFound = -32002, // Standard JSON-RPC error codes ParseError = -32700, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 8564212c1..0a0c193e3 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -177,15 +177,17 @@ export class McpServer { ); this.server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => { - try { - const tool = this._registeredTools[request.params.name]; - if (!tool) { - throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`); - } - if (!tool.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); - } + // Unknown tool is a protocol error per MCP spec. + // Check before try/catch block so it propagates as a JSON-RPC error instead of CallToolResult. + const tool = this._registeredTools[request.params.name]; + if (!tool) { + throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`); + } + if (!tool.enabled) { + throw new McpError(ErrorCode.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); @@ -557,7 +559,7 @@ export class McpServer { } } - throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`); + throw new McpError(ErrorCode.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 f7bcececc..684924c79 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1707,25 +1707,68 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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: ErrorCode.InvalidParams, + message: expect.stringContaining('nonexistent-tool') + }); + }); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + /*** + * Test: McpError for Disabled Tool + */ + test('should throw McpError 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.tool('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: ErrorCode.InvalidParams, + message: expect.stringContaining('disabled') + }); }); /*** @@ -2753,7 +2796,56 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }, ReadResourceResultSchema ) - ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); + ).rejects.toMatchObject({ + code: ErrorCode.ResourceNotFound, + message: expect.stringContaining('not found') + }); + }); + + /*** + * Test: McpError for Disabled Resource + */ + test('should throw McpError 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.resource('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: ErrorCode.InvalidParams, + message: expect.stringContaining('disabled') + }); }); /*** @@ -3667,7 +3759,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }, GetPromptResultSchema ) - ).rejects.toThrow(/Prompt nonexistent-prompt not found/); + ).rejects.toMatchObject({ + code: ErrorCode.InvalidParams, + message: expect.stringContaining('not found') + }); }); /***