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
27 changes: 27 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
73 changes: 73 additions & 0 deletions examples/servers/typescript/tools-call-unknown-tool-as-result.ts
Original file line number Diff line number Diff line change
@@ -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`
);
});
2 changes: 2 additions & 0 deletions src/scenarios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -189,6 +190,7 @@ const allClientScenariosList: ClientScenario[] = [
new ToolsCallMultipleContentTypesScenario(),
new ToolsCallWithLoggingScenario(),
new ToolsCallErrorScenario(),
new ToolsCallProtocolErrorScenario(),
new ToolsCallWithProgressScenario(),
new ToolsCallSamplingScenario(),
new ToolsCallElicitationScenario(),
Expand Down
121 changes: 121 additions & 0 deletions src/scenarios/server/tools-call-protocol-error.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<ChildProcess> {
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<void> {
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);
});
Loading