From 7f7805caf0b38de7a9f95a01cd5c2bad4c73b798 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 14:32:55 +0000 Subject: [PATCH 1/3] fix: bump SDK to 1.26.0, add session management to tools_call scenario SDK 1.26.0 made Protocol.connect() throw if already connected to a transport. The tools_call scenario was calling server.connect(transport) on every request with the same Server instance, which now throws. Fixed by adding proper session management: each initialize request creates a new Server + Transport pair, and subsequent requests are routed to the correct transport via the mcp-session-id header. --- package-lock.json | 60 ++++++++++++++---------- package.json | 2 +- src/scenarios/client/tools_call.ts | 74 ++++++++++++++++++++++++++---- 3 files changed, 104 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7a9ab2e..1ccf3ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.12", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", @@ -752,9 +752,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -855,12 +855,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz", - "integrity": "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -868,14 +868,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -3042,17 +3043,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -3083,10 +3086,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, "engines": { "node": ">= 16" }, @@ -3405,7 +3411,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -3501,6 +3506,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3556,9 +3570,9 @@ } }, "node_modules/jose": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.2.tgz", - "integrity": "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/package.json b/package.json index 81a7ce0..19b4cee 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "vitest": "^4.0.16" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.2", + "@modelcontextprotocol/sdk": "^1.26.0", "commander": "^14.0.2", "eventsource-parser": "^3.0.6", "express": "^5.1.0", diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index ab7e312..321947d 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -4,12 +4,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import type { Scenario, ConformanceCheck } from '../../types'; import express, { Request, Response } from 'express'; import { ScenarioUrls } from '../../types'; import { createRequestLogger } from '../request-logger'; +import { randomUUID } from 'crypto'; -function createServer(checks: ConformanceCheck[]): express.Application { +function createMcpServer(checks: ConformanceCheck[]): Server { const server = new Server( { name: 'add-numbers-server', @@ -84,6 +86,16 @@ function createServer(checks: ConformanceCheck[]): express.Application { throw new Error(`Unknown tool: ${request.params.name}`); }); + return server; +} + +function createApp(checks: ConformanceCheck[]): { + app: express.Application; + cleanup: () => void; +} { + const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + const servers: { [sessionId: string]: Server } = {}; + const app = express(); app.use(express.json()); @@ -96,15 +108,54 @@ function createServer(checks: ConformanceCheck[]): express.Application { ); app.post('/mcp', async (req: Request, res: Response) => { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); + const sessionId = req.headers['mcp-session-id'] as string | undefined; - await transport.handleRequest(req, res, req.body); + if (sessionId && transports[sessionId]) { + await transports[sessionId].handleRequest(req, res, req.body); + } else if (!sessionId && isInitializeRequest(req.body)) { + const mcpServer = createMcpServer(checks); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + } else { + res.status(400).json({ error: 'Bad request' }); + } + }); + + app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && transports[sessionId]) { + await transports[sessionId].handleRequest(req, res); + } else { + res.status(404).json({ error: 'Session not found' }); + } }); - return app; + const cleanup = () => { + for (const sid in servers) { + servers[sid].close(); + } + }; + + return { app, cleanup }; } export class ToolsCallScenario implements Scenario { @@ -113,16 +164,23 @@ export class ToolsCallScenario implements Scenario { private app: express.Application | null = null; private httpServer: any = null; private checks: ConformanceCheck[] = []; + private cleanup: (() => void) | null = null; async start(): Promise { this.checks = []; - this.app = createServer(this.checks); + const result = createApp(this.checks); + this.app = result.app; + this.cleanup = result.cleanup; this.httpServer = this.app.listen(0); const port = this.httpServer.address().port; return { serverUrl: `http://localhost:${port}/mcp` }; } async stop() { + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; + } if (this.httpServer) { await new Promise((resolve) => this.httpServer.close(resolve)); this.httpServer = null; From d59148871805112eda66c1e587a5aa2fc8de5749 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 14:47:06 +0000 Subject: [PATCH 2/3] fix: return 404 for invalid session IDs in tools_call POST handler The else branch was returning 400 for both missing-session and invalid/stale-session cases. The MCP spec requires 404 for invalid session IDs and 400 only for non-initialization requests without any session ID. --- src/scenarios/client/tools_call.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index 321947d..ab17429 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -135,7 +135,11 @@ function createApp(checks: ConformanceCheck[]): { await mcpServer.connect(transport); await transport.handleRequest(req, res, req.body); + } else if (sessionId) { + // Invalid/stale session ID → 404 + res.status(404).json({ error: 'Session not found' }); } else { + // Non-initialization request without session ID → 400 res.status(400).json({ error: 'Bad request' }); } }); From 2f87479b33fe9dcfac86d79a50828b94a05e10c4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 5 Feb 2026 15:26:25 +0000 Subject: [PATCH 3/3] refactor: keep tools_call stateless, create fresh server per request Instead of adding session management, simply create a new Server instance per request. This preserves the original stateless design while fixing the SDK 1.26.0 Protocol.connect() restriction. --- src/scenarios/client/tools_call.ts | 78 +++++------------------------- 1 file changed, 11 insertions(+), 67 deletions(-) diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index ab17429..b074773 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -4,12 +4,10 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import type { Scenario, ConformanceCheck } from '../../types'; import express, { Request, Response } from 'express'; import { ScenarioUrls } from '../../types'; import { createRequestLogger } from '../request-logger'; -import { randomUUID } from 'crypto'; function createMcpServer(checks: ConformanceCheck[]): Server { const server = new Server( @@ -89,13 +87,7 @@ function createMcpServer(checks: ConformanceCheck[]): Server { return server; } -function createApp(checks: ConformanceCheck[]): { - app: express.Application; - cleanup: () => void; -} { - const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - const servers: { [sessionId: string]: Server } = {}; - +function createServerApp(checks: ConformanceCheck[]): express.Application { const app = express(); app.use(express.json()); @@ -108,58 +100,17 @@ function createApp(checks: ConformanceCheck[]): { ); app.post('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res, req.body); - } else if (!sessionId && isInitializeRequest(req.body)) { - const mcpServer = createMcpServer(checks); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (newSessionId) => { - transports[newSessionId] = transport; - servers[newSessionId] = mcpServer; - } - }); - - transport.onclose = () => { - const sid = transport.sessionId; - if (sid && transports[sid]) { - delete transports[sid]; - if (servers[sid]) { - servers[sid].close(); - delete servers[sid]; - } - } - }; - - await mcpServer.connect(transport); - await transport.handleRequest(req, res, req.body); - } else if (sessionId) { - // Invalid/stale session ID → 404 - res.status(404).json({ error: 'Session not found' }); - } else { - // Non-initialization request without session ID → 400 - res.status(400).json({ error: 'Bad request' }); - } - }); - - app.delete('/mcp', async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && transports[sessionId]) { - await transports[sessionId].handleRequest(req, res); - } else { - res.status(404).json({ error: 'Session not found' }); - } + // Stateless: create a fresh server and transport per request + const server = createMcpServer(checks); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); }); - const cleanup = () => { - for (const sid in servers) { - servers[sid].close(); - } - }; - - return { app, cleanup }; + return app; } export class ToolsCallScenario implements Scenario { @@ -168,23 +119,16 @@ export class ToolsCallScenario implements Scenario { private app: express.Application | null = null; private httpServer: any = null; private checks: ConformanceCheck[] = []; - private cleanup: (() => void) | null = null; async start(): Promise { this.checks = []; - const result = createApp(this.checks); - this.app = result.app; - this.cleanup = result.cleanup; + this.app = createServerApp(this.checks); this.httpServer = this.app.listen(0); const port = this.httpServer.address().port; return { serverUrl: `http://localhost:${port}/mcp` }; } async stop() { - if (this.cleanup) { - this.cleanup(); - this.cleanup = null; - } if (this.httpServer) { await new Promise((resolve) => this.httpServer.close(resolve)); this.httpServer = null;