diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index c76c1f56..b0b67b81 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -20,7 +20,10 @@ import { } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import { + CallToolRequestSchema, ElicitResultSchema, + ErrorCode, + McpError, ResultSchema, ProgressNotificationSchema, LoggingMessageNotificationSchema, @@ -241,6 +244,30 @@ function createMcpServer() { ListResourceTemplatesRequestSchema ]); mcpServer.server.setRequestHandler = ((schema: any, handler: any) => { + if (schema === CallToolRequestSchema) { + // The pinned 1.x McpServer folds its own "Tool X not found" + // InvalidParams error into an isError CallToolResult. The spec + // (Tools > Error Handling) and the 2.x SDK report an unknown tool as a + // JSON-RPC protocol error, which tools-call-protocol-error checks for, + // so throw it before the SDK's tool-result wrapper can catch it. + return originalSetRequestHandler( + schema, + async (request: any, ...rest: any[]) => { + // Access internal registered tools (this is internal SDK API but stable) + const registeredTools = (mcpServer as any)._registeredTools as Record< + string, + unknown + >; + if (!(request.params.name in registeredTools)) { + throw new McpError( + ErrorCode.InvalidParams, + `Tool ${request.params.name} not found` + ); + } + return handler(request, ...rest); + } + ); + } if (listSchemasForCaching.has(schema)) { return originalSetRequestHandler(schema, async (...args: any[]) => { const result = await handler(...args); diff --git a/examples/servers/typescript/tools-call-unknown-tool-as-result.ts b/examples/servers/typescript/tools-call-unknown-tool-as-result.ts new file mode 100644 index 00000000..33bd9ea6 --- /dev/null +++ b/examples/servers/typescript/tools-call-unknown-tool-as-result.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +/** + * tools-call-protocol-error negative test server. + * + * Speaks the stateless wire (SEP-2575) and breaks the two rules the + * tools-call-protocol-error scenario exists to catch: a tools/call for a + * tool it does not have is answered with a CallToolResult carrying + * `isError: true` instead of a JSON-RPC error response, and every response + * id is emitted as a string, so a numeric request id comes back coerced. + * The tools/list result is otherwise well formed. + */ + +import express from 'express'; + +const app = express(); +app.use(express.json()); + +const caching = { resultType: 'complete', ttlMs: 0, cacheScope: 'private' }; + +app.post('/mcp', (req, res) => { + const body = req.body || {}; + // Deliberate defect: ids are stringified on the way out. + const id = body.id === undefined || body.id === null ? null : String(body.id); + switch (body.method) { + case 'server/discover': + return res.json({ + jsonrpc: '2.0', + id, + result: { + ...caching, + supportedVersions: ['2026-07-28'], + capabilities: { tools: {} }, + serverInfo: { + name: 'tools-call-unknown-tool-as-result', + version: '1.0.0' + } + } + }); + case 'tools/list': + return res.json({ + jsonrpc: '2.0', + id, + result: { ...caching, tools: [] } + }); + case 'tools/call': + // Deliberate defect: an unknown tool reported as a tool execution error. + return res.json({ + jsonrpc: '2.0', + id, + result: { + ...caching, + isError: true, + content: [ + { type: 'text', text: `Unknown tool: ${body.params?.name}` } + ] + } + }); + default: + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${body.method}` } + }); + } +}); + +const port = parseInt(process.env.PORT || '3000', 10); +app.listen(port, () => { + console.log( + `tools-call-unknown-tool-as-result server running on http://localhost:${port}/mcp` + ); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 375d7010..350e21e4 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -44,6 +44,7 @@ import { } from './server/tools'; import { JsonSchema2020_12Scenario } from './server/json-schema-2020-12'; +import { ToolsCallProtocolErrorScenario } from './server/tools-call-protocol-error'; import { ElicitationDefaultsScenario } from './server/elicitation-defaults'; import { ElicitationEnumsScenario } from './server/elicitation-enums'; @@ -189,6 +190,7 @@ const allClientScenariosList: ClientScenario[] = [ new ToolsCallMultipleContentTypesScenario(), new ToolsCallWithLoggingScenario(), new ToolsCallErrorScenario(), + new ToolsCallProtocolErrorScenario(), new ToolsCallWithProgressScenario(), new ToolsCallSamplingScenario(), new ToolsCallElicitationScenario(), diff --git a/src/scenarios/server/tools-call-protocol-error.test.ts b/src/scenarios/server/tools-call-protocol-error.test.ts new file mode 100644 index 00000000..c60594dd --- /dev/null +++ b/src/scenarios/server/tools-call-protocol-error.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'net'; +import path from 'path'; +import { testContext } from '../../connection/testing'; +import { ToolsCallProtocolErrorScenario } from './tools-call-protocol-error'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function startServer(scriptPath: string, port: number): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString() }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject(new Error(`Server failed to start within 30s: ${stderr}`)); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +describe('tools-call-protocol-error negative test', () => { + let serverProcess: ChildProcess | null = null; + let serverUrl: string; + + beforeAll(async () => { + const port = await getFreePort(); + serverUrl = `http://localhost:${port}/mcp`; + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/tools-call-unknown-tool-as-result.ts' + ), + port + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('fails the protocol-error and id checks against a server that answers an unknown tool with isError and stringifies ids', async () => { + const checks = await new ToolsCallProtocolErrorScenario().run( + testContext(serverUrl, DRAFT_PROTOCOL_VERSION) + ); + const byId = new Map(checks.map((c) => [c.id, c])); + + expect(byId.get('tools-call-unknown-tool-protocol-error')?.status).toBe( + 'FAILURE' + ); + expect( + byId.get('tools-call-unknown-tool-protocol-error')?.errorMessage + ).toMatch(/isError: true/); + // The error-frame checks cannot run without an error frame and report that + // rather than SKIPPED (issue #248). + expect(byId.get('jsonrpc-error-code-integer')?.status).toBe('FAILURE'); + expect(byId.get('jsonrpc-error-code-integer')?.errorMessage).toMatch( + /^Not testable:/ + ); + expect(byId.get('tools-call-unknown-tool-error-code')?.status).toBe( + 'WARNING' + ); + // The result path is independent of the error path: a string id survives + // this fixture's stringification, so the result-id check passes here... + expect( + byId.get('jsonrpc-result-response-id-string-preserved')?.status + ).toBe('SUCCESS'); + // ...while every check id is emitted exactly once. + expect(checks.map((c) => c.id).sort()).toEqual( + [ + 'jsonrpc-error-code-integer', + 'jsonrpc-error-message-string', + 'jsonrpc-error-response-id-matches', + 'jsonrpc-error-response-id-string-preserved', + 'jsonrpc-error-response-no-result', + 'jsonrpc-result-response-id-string-preserved', + 'tools-call-unknown-tool-error-code', + 'tools-call-unknown-tool-protocol-error' + ].sort() + ); + }, 20000); +}); diff --git a/src/scenarios/server/tools-call-protocol-error.ts b/src/scenarios/server/tools-call-protocol-error.ts new file mode 100644 index 00000000..2ac5201f --- /dev/null +++ b/src/scenarios/server/tools-call-protocol-error.ts @@ -0,0 +1,565 @@ +import type { + ClientScenario, + ConformanceCheck, + SpecReference +} from '../../types'; +import type { SpecVersion } from '../../types'; +import type { RunContext } from '../../connection'; +import { + buildStandardHeaders, + readSseJsonRpcResponse, + withRequestMeta, + CONFORMANCE_CLIENT_INFO, + DEFAULT_CLIENT_CAPABILITIES, + type JsonRpcResponse +} from '../../connection'; +import { isStateless } from '../../connection/select'; +import { validateWireMessage } from '../../validation/wire-schema'; + +// Trace: the probes below are the wire-level half of the reject vectors +// `a8-error-response-has-no-preimage` and `a4-duplicate-member` in the +// agent-evidence-vectors corpus (vectors-ai-agent-action/attacks), which +// treat a JSON-RPC error frame and the id it carries as the only evidence a +// failed tools/call leaves behind. + +const SPEC_TOOLS_ERROR_HANDLING: SpecReference = { + id: 'MCP-Tools-Error-Handling', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling' +}; +const SPEC_ERROR_RESPONSES: SpecReference = { + id: 'MCP-Error-Responses', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#error-responses' +}; +const SPEC_RESULT_RESPONSES: SpecReference = { + id: 'MCP-Result-Responses', + url: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#result-responses' +}; +const SPEC_JSONRPC_RESPONSE: SpecReference = { + id: 'JSON-RPC-2.0-Response', + url: 'https://www.jsonrpc.org/specification#response_object' +}; + +const NUMERIC_PROBE_ID = 4242; +const STRING_PROBE_ID = 'conformance-unknown-tool-probe'; +const STRING_LIST_ID = 'conformance-tools-list-probe'; +const UNKNOWN_TOOL_NAME = 'conformance_tool_that_does_not_exist'; +const REQUEST_TIMEOUT_MS = 10000; + +interface RawFrame { + status: number; + frame?: JsonRpcResponse; + rawBody?: string; +} + +/** + * Minimal session for the dated (stateful) wire: a raw initialize followed by + * notifications/initialized. Returns the session id header, if any. The + * stateless draft wire needs no handshake and returns undefined. + */ +async function openSession( + serverUrl: string, + specVersion: SpecVersion +): Promise<{ sessionId?: string }> { + if (isStateless({ specVersion })) return {}; + const initRes = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': specVersion + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: specVersion, + capabilities: DEFAULT_CLIENT_CAPABILITIES, + clientInfo: CONFORMANCE_CLIENT_INFO + } + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + const sessionId = initRes.headers.get('mcp-session-id') ?? undefined; + // Drain the initialize body so the connection is released. + if ( + (initRes.headers.get('content-type') ?? '').includes('text/event-stream') + ) { + await readSseJsonRpcResponse(initRes, 1); + } else { + await initRes.text(); + } + if (initRes.status >= 400) { + throw new Error(`initialize returned HTTP ${initRes.status}`); + } + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': specVersion + }; + if (sessionId) headers['Mcp-Session-Id'] = sessionId; + const notifyRes = await fetch(serverUrl, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized' + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + await notifyRes.text(); + return { sessionId }; +} + +/** + * POST one JSON-RPC request with a caller-chosen id and return the response + * frame exactly as the server sent it. The shared connection layer picks its + * own numeric ids and throws on error frames, and this scenario needs to see + * both the id and the error frame as bytes on the wire. + */ +async function sendRaw( + serverUrl: string, + specVersion: SpecVersion, + sessionId: string | undefined, + id: number | string, + method: string, + params: Record +): Promise { + const stateless = isStateless({ specVersion }); + const headers = stateless + ? buildStandardHeaders(method, params, { specVersion }) + : { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': specVersion, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}) + }; + const request = { + jsonrpc: '2.0', + id, + method, + params: stateless ? withRequestMeta(params, specVersion) : params + }; + const res = await fetch(serverUrl, { + method: 'POST', + headers, + body: JSON.stringify(request), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + const contentType = res.headers.get('content-type') ?? ''; + if (contentType.includes('text/event-stream')) { + const { events, body } = await readSseJsonRpcResponse(res, id); + for (const event of events) { + validateWireMessage(specVersion, event, { + origin: 'implementation', + context: `SSE event during '${method}' (tools-call-protocol-error)`, + requestMethod: event === body ? method : undefined + }); + } + return { status: res.status, frame: body }; + } + const text = await res.text(); + if (!text) return { status: res.status, rawBody: text }; + try { + const parsed = JSON.parse(text) as JsonRpcResponse; + validateWireMessage(specVersion, parsed, { + origin: 'implementation', + context: `response to '${method}' (tools-call-protocol-error)`, + requestMethod: method + }); + return { status: res.status, frame: parsed, rawBody: text }; + } catch { + return { status: res.status, rawBody: text }; + } +} + +function describeId(id: unknown): string { + return `${JSON.stringify(id)} (${id === null ? 'null' : typeof id})`; +} + +export class ToolsCallProtocolErrorScenario implements ClientScenario { + name = 'tools-call-protocol-error'; + readonly source = { introducedIn: '2025-06-18' } as const; + description = `Test that a tools/call for a tool the server does not have is answered with a JSON-RPC error response whose frame is well formed and correlated to the request. + +**Server Implementation Requirements:** + +No fixture tool is needed. The scenario calls a tool name that must NOT exist (\`${UNKNOWN_TOOL_NAME}\`), once with a numeric request id and once with a string request id, and then calls \`tools/list\` with a string request id. + +**Behavior**: An unknown tool is a protocol error, not a tool execution error. The server answers with a JSON-RPC error response (an \`error\` member and no \`result\`), not with a \`CallToolResult\` carrying \`isError: true\`. + +**Checks** (spec keyword in parentheses): +- \`tools-call-unknown-tool-protocol-error\`: the response is an error frame, not an \`isError\` result (Tools > Error Handling: unknown tools are protocol errors) +- \`tools-call-unknown-tool-error-code\`: \`error.code\` is \`-32602\`, the code the specification's own example uses (SHOULD-level, WARNING) +- \`jsonrpc-error-code-integer\`: \`error.code\` is an integer (MUST) +- \`jsonrpc-error-message-string\`: \`error.message\` is a string (MUST) +- \`jsonrpc-error-response-no-result\`: an error response carries no \`result\` member (JSON-RPC 2.0 MUST NOT) +- \`jsonrpc-error-response-id-matches\`: the error response echoes the numeric request id exactly (MUST) +- \`jsonrpc-error-response-id-string-preserved\`: a string request id comes back as the same string, not coerced to a number (MUST) +- \`jsonrpc-result-response-id-string-preserved\`: the \`tools/list\` result echoes a string request id as the same string (MUST)`; + + async run(ctx: RunContext): Promise { + const checks: ConformanceCheck[] = []; + const { serverUrl, specVersion } = ctx; + const now = () => new Date().toISOString(); + + const fail = ( + id: string, + name: string, + description: string, + errorMessage: string, + specReferences: SpecReference[], + details?: Record, + status: 'FAILURE' | 'WARNING' = 'FAILURE' + ): ConformanceCheck => ({ + id, + name, + description, + status, + timestamp: now(), + errorMessage, + specReferences, + details + }); + const pass = ( + id: string, + name: string, + description: string, + specReferences: SpecReference[], + details?: Record + ): ConformanceCheck => ({ + id, + name, + description, + status: 'SUCCESS', + timestamp: now(), + specReferences, + details + }); + + let sessionId: string | undefined; + try { + ({ sessionId } = await openSession(serverUrl, specVersion)); + } catch (error) { + const message = `Could not open a session: ${error instanceof Error ? error.message : String(error)}`; + for (const [id, name, description] of CHECK_DEFS) { + checks.push( + fail(id, name, description, message, [SPEC_ERROR_RESPONSES]) + ); + } + return checks; + } + + // Probe 1: unknown tool, numeric id. + let numeric: RawFrame; + try { + numeric = await sendRaw( + serverUrl, + specVersion, + sessionId, + NUMERIC_PROBE_ID, + 'tools/call', + { name: UNKNOWN_TOOL_NAME, arguments: {} } + ); + } catch (error) { + numeric = { + status: 0, + rawBody: `request failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + const frame = numeric.frame; + const frameDetails = { + httpStatus: numeric.status, + response: frame ?? numeric.rawBody + }; + + const isErrorFrame = frame !== undefined && 'error' in frame; + const isErrorResult = + frame !== undefined && + 'result' in frame && + (frame.result as { isError?: unknown } | undefined)?.isError === true; + + checks.push( + isErrorFrame + ? pass( + 'tools-call-unknown-tool-protocol-error', + 'ToolsCallUnknownToolProtocolError', + 'Calling a tool the server does not have yields a JSON-RPC error response', + [SPEC_TOOLS_ERROR_HANDLING], + frameDetails + ) + : fail( + 'tools-call-unknown-tool-protocol-error', + 'ToolsCallUnknownToolProtocolError', + 'Calling a tool the server does not have yields a JSON-RPC error response', + frame === undefined + ? `No JSON-RPC response frame was returned (HTTP ${numeric.status})` + : isErrorResult + ? 'Unknown tool was reported as a tool execution error (result.isError: true) instead of a JSON-RPC protocol error' + : 'Unknown tool was answered with a result instead of a JSON-RPC error response', + [SPEC_TOOLS_ERROR_HANDLING], + frameDetails + ) + ); + + const err = isErrorFrame + ? (frame.error as { code?: unknown; message?: unknown }) + : undefined; + const errorFramePrereq = isErrorFrame + ? undefined + : 'Not testable: the server did not return a JSON-RPC error response for the unknown tool'; + + checks.push( + err?.code === -32602 + ? pass( + 'tools-call-unknown-tool-error-code', + 'ToolsCallUnknownToolErrorCode', + 'Unknown tool error uses code -32602 (Invalid params), as in the specification example', + [SPEC_TOOLS_ERROR_HANDLING], + frameDetails + ) + : fail( + 'tools-call-unknown-tool-error-code', + 'ToolsCallUnknownToolErrorCode', + 'Unknown tool error uses code -32602 (Invalid params), as in the specification example', + errorFramePrereq ?? + `Expected error.code -32602, got ${JSON.stringify(err?.code)}`, + [SPEC_TOOLS_ERROR_HANDLING], + frameDetails, + 'WARNING' + ) + ); + + checks.push( + err !== undefined && Number.isInteger(err.code) + ? pass( + 'jsonrpc-error-code-integer', + 'JsonRpcErrorCodeInteger', + 'Error codes MUST be integers', + [SPEC_ERROR_RESPONSES], + frameDetails + ) + : fail( + 'jsonrpc-error-code-integer', + 'JsonRpcErrorCodeInteger', + 'Error codes MUST be integers', + errorFramePrereq ?? + `error.code is ${JSON.stringify(err?.code)}, not an integer`, + [SPEC_ERROR_RESPONSES], + frameDetails + ) + ); + + checks.push( + err !== undefined && typeof err.message === 'string' + ? pass( + 'jsonrpc-error-message-string', + 'JsonRpcErrorMessageString', + 'Error responses MUST include an error field with a code and message', + [SPEC_ERROR_RESPONSES], + frameDetails + ) + : fail( + 'jsonrpc-error-message-string', + 'JsonRpcErrorMessageString', + 'Error responses MUST include an error field with a code and message', + errorFramePrereq ?? + `error.message is ${JSON.stringify(err?.message)}, not a string`, + [SPEC_ERROR_RESPONSES], + frameDetails + ) + ); + + checks.push( + isErrorFrame && !('result' in frame) + ? pass( + 'jsonrpc-error-response-no-result', + 'JsonRpcErrorResponseNoResult', + 'An error response carries no result member', + [SPEC_JSONRPC_RESPONSE, SPEC_ERROR_RESPONSES], + frameDetails + ) + : fail( + 'jsonrpc-error-response-no-result', + 'JsonRpcErrorResponseNoResult', + 'An error response carries no result member', + errorFramePrereq ?? + 'Error response carries both error and result members', + [SPEC_JSONRPC_RESPONSE, SPEC_ERROR_RESPONSES], + frameDetails + ) + ); + + checks.push( + isErrorFrame && frame.id === NUMERIC_PROBE_ID + ? pass( + 'jsonrpc-error-response-id-matches', + 'JsonRpcErrorResponseIdMatches', + 'Error responses MUST include the same ID as the request they correspond to', + [SPEC_ERROR_RESPONSES], + frameDetails + ) + : fail( + 'jsonrpc-error-response-id-matches', + 'JsonRpcErrorResponseIdMatches', + 'Error responses MUST include the same ID as the request they correspond to', + errorFramePrereq ?? + `Request id was ${describeId(NUMERIC_PROBE_ID)}, response id is ${describeId(frame?.id)}`, + [SPEC_ERROR_RESPONSES], + frameDetails + ) + ); + + // Probe 2: unknown tool, string id. Exercises the id type, which a server + // that parses ids as numbers or stringifies them silently breaks. + let stringProbe: RawFrame; + try { + stringProbe = await sendRaw( + serverUrl, + specVersion, + sessionId, + STRING_PROBE_ID, + 'tools/call', + { name: UNKNOWN_TOOL_NAME, arguments: {} } + ); + } catch (error) { + stringProbe = { + status: 0, + rawBody: `request failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + const sFrame = stringProbe.frame; + const sDetails = { + httpStatus: stringProbe.status, + response: sFrame ?? stringProbe.rawBody + }; + checks.push( + sFrame !== undefined && 'error' in sFrame && sFrame.id === STRING_PROBE_ID + ? pass( + 'jsonrpc-error-response-id-string-preserved', + 'JsonRpcErrorResponseIdStringPreserved', + 'A string request id is echoed on the error response as the same string', + [SPEC_ERROR_RESPONSES], + sDetails + ) + : fail( + 'jsonrpc-error-response-id-string-preserved', + 'JsonRpcErrorResponseIdStringPreserved', + 'A string request id is echoed on the error response as the same string', + sFrame === undefined + ? `No JSON-RPC response frame was returned (HTTP ${stringProbe.status})` + : !('error' in sFrame) + ? 'Not testable: the server did not return a JSON-RPC error response for the unknown tool' + : `Request id was ${describeId(STRING_PROBE_ID)}, response id is ${describeId(sFrame.id)}`, + [SPEC_ERROR_RESPONSES], + sDetails + ) + ); + + // Probe 3: a valid request with a string id, so the id rule is checked on + // the result path too and not only on the error path. + let listProbe: RawFrame; + try { + listProbe = await sendRaw( + serverUrl, + specVersion, + sessionId, + STRING_LIST_ID, + 'tools/list', + {} + ); + } catch (error) { + listProbe = { + status: 0, + rawBody: `request failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + const lFrame = listProbe.frame; + const lDetails = { + httpStatus: listProbe.status, + response: lFrame ?? listProbe.rawBody + }; + checks.push( + lFrame !== undefined && 'result' in lFrame && lFrame.id === STRING_LIST_ID + ? pass( + 'jsonrpc-result-response-id-string-preserved', + 'JsonRpcResultResponseIdStringPreserved', + 'Result responses MUST include the same ID as the request they correspond to, including a string id', + [SPEC_RESULT_RESPONSES], + lDetails + ) + : fail( + 'jsonrpc-result-response-id-string-preserved', + 'JsonRpcResultResponseIdStringPreserved', + 'Result responses MUST include the same ID as the request they correspond to, including a string id', + lFrame === undefined + ? `No JSON-RPC response frame was returned (HTTP ${listProbe.status})` + : !('result' in lFrame) + ? `tools/list with a string id was answered with an error: ${JSON.stringify(lFrame.error)}` + : `Request id was ${describeId(STRING_LIST_ID)}, response id is ${describeId(lFrame.id)}`, + [SPEC_RESULT_RESPONSES], + lDetails + ) + ); + + if (sessionId && !isStateless({ specVersion })) { + try { + await fetch(serverUrl, { + method: 'DELETE', + headers: { + 'Mcp-Session-Id': sessionId, + 'MCP-Protocol-Version': specVersion + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }); + } catch { + // Session cleanup is best-effort; the checks above already ran. + } + } + + return checks; + } +} + +const CHECK_DEFS: ReadonlyArray<[string, string, string]> = [ + [ + 'tools-call-unknown-tool-protocol-error', + 'ToolsCallUnknownToolProtocolError', + 'Calling a tool the server does not have yields a JSON-RPC error response' + ], + [ + 'tools-call-unknown-tool-error-code', + 'ToolsCallUnknownToolErrorCode', + 'Unknown tool error uses code -32602 (Invalid params), as in the specification example' + ], + [ + 'jsonrpc-error-code-integer', + 'JsonRpcErrorCodeInteger', + 'Error codes MUST be integers' + ], + [ + 'jsonrpc-error-message-string', + 'JsonRpcErrorMessageString', + 'Error responses MUST include an error field with a code and message' + ], + [ + 'jsonrpc-error-response-no-result', + 'JsonRpcErrorResponseNoResult', + 'An error response carries no result member' + ], + [ + 'jsonrpc-error-response-id-matches', + 'JsonRpcErrorResponseIdMatches', + 'Error responses MUST include the same ID as the request they correspond to' + ], + [ + 'jsonrpc-error-response-id-string-preserved', + 'JsonRpcErrorResponseIdStringPreserved', + 'A string request id is echoed on the error response as the same string' + ], + [ + 'jsonrpc-result-response-id-string-preserved', + 'JsonRpcResultResponseIdStringPreserved', + 'Result responses MUST include the same ID as the request they correspond to, including a string id' + ] +];