From 725784f2a9138fad9a08f96bd4f3cbc6a2639eef Mon Sep 17 00:00:00 2001 From: po-et <42566883+po-et@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:10:00 +0800 Subject: [PATCH] fix(everything-server): classify by the wire's markers, not by the presence of _meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_meta` belongs to the base request shape in every revision — progress tokens, trace context, whatever a client attaches — so the fixture cannot read its presence as "2026-07-28 traffic". It did, and session-era requests that carry it were rejected instead of served: initialize (2025-11-25) + `_meta: {}`, no header -> 400 -32020 Missing MCP-Protocol-Version header initialize (2025-11-25) + `_meta: {}`, era header -> 400 -32602 Invalid params: missing _meta or required fields MCP Python SDK 2.x sends `_meta: {}` on `initialize`, which is what makes the fixture unusable as its upstream server (#506). Two markers identify the request-meta wire: a `MCP-Protocol-Version` header naming a post-session revision, and the per-request metadata envelope, whose own marker is `io.modelcontextprotocol/protocolVersion` inside `_meta`. Classify on those. Session-era traffic keeps the session path whether or not it carries `_meta`, and the rejections the envelope exists to trigger are untouched: a request-meta-era request that omits the header still answers -32020, and an envelope missing its protocol version still answers -32602. Every scenario probe reaches the modern path through `buildStandardHeaders`, which always sets `MCP-Protocol-Version`, so the 55 server scenarios are unaffected — verified, plus a vitest case pinning both halves. --- .../servers/typescript/everything-server.ts | 26 ++- ...hing-server-request-classification.test.ts | 202 ++++++++++++++++++ 2 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 src/scenarios/server/everything-server-request-classification.test.ts diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index c76c1f56..3d6894af 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -1284,15 +1284,29 @@ app.post('/mcp', async (req, res) => { const meta = params._meta; const metaVersion = meta?.['io.modelcontextprotocol/protocolVersion']; - // A request that carries no `_meta` and names a legacy session-era revision - // in the header is legacy traffic; it is served by the session path below - // instead of being rejected for missing per-request metadata. - const isLegacySessionEraRequest = - meta === undefined && + // Which wire is this? `_meta` cannot answer it: `_meta` belongs to the base + // request shape in EVERY revision (progress tokens, trace context, anything + // a client attaches), so its presence alone does not make a request + // 2026-07-28 traffic. Two things do identify that wire: + // + // - a `MCP-Protocol-Version` header naming a post-session revision, and + // - the per-request metadata envelope, whose own marker is + // `io.modelcontextprotocol/protocolVersion` inside `_meta`. + // + // Keying on the presence of `_meta` rejected session-era traffic that + // legitimately carries it: a stateful `initialize` with `_meta` was answered + // -32020 (no header) or -32602 (session-era header) instead of being served. + // MCP Python SDK 2.x sends `_meta: {}` on `initialize`, which made this + // fixture unusable as its upstream server (#506). + const carriesRequestMetaEnvelope = metaVersion !== undefined; + const namesSessionEraRevision = reqVersion !== undefined && LEGACY_SESSION_PROTOCOL_VERSIONS.includes(reqVersion); + const isRequestMetaEraRequest = + (reqVersion !== undefined && !namesSessionEraRevision) || + carriesRequestMetaEnvelope; - if (!sessionId && (reqVersion || meta) && !isLegacySessionEraRequest) { + if (!sessionId && isRequestMetaEraRequest) { // Missing Transport Header Validation Check if (!reqVersion) { return res.status(400).json({ diff --git a/src/scenarios/server/everything-server-request-classification.test.ts b/src/scenarios/server/everything-server-request-classification.test.ts new file mode 100644 index 00000000..75264f02 --- /dev/null +++ b/src/scenarios/server/everything-server-request-classification.test.ts @@ -0,0 +1,202 @@ +/** + * Which wire does the everything-server think a request is on? + * + * `_meta` is part of the base request shape in every revision, so a + * session-era request may carry it (MCP Python SDK 2.x sends `_meta: {}` on + * `initialize`). Classifying by its presence rejected that traffic — -32020 + * without a header, -32602 with a session-era one — and made the fixture + * unusable as an upstream server for those clients (#506). + * + * These probes pin both halves: session-era traffic carrying `_meta` is + * served, and the 2026-07-28 rejections the per-request metadata envelope is + * there to trigger still fire. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'net'; +import path from 'path'; + +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 isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString() }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${scriptPath} 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('everything-server request classification (#506)', () => { + let serverProcess: ChildProcess | null = null; + let serverUrl = ''; + + beforeAll(async () => { + const port = await getFreePort(); + serverUrl = `http://127.0.0.1:${port}/mcp`; + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/everything-server.ts' + ), + port + ); + }, 60000); + + afterAll(async () => { + await stopServer(serverProcess); + serverProcess = null; + }); + + /** One POST, no session, with exactly the headers the caller names. */ + async function post( + body: Record, + headers: Record = {} + ): Promise<{ status: number; payload: any }> { + const res = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify(body) + }); + const text = await res.text(); + const line = text + .split('\n') + .map((l) => (l.startsWith('data:') ? l.slice(5).trim() : l.trim())) + .find((l) => l.startsWith('{')); + return { status: res.status, payload: line ? JSON.parse(line) : null }; + } + + const sessionEraInitialize = (meta: Record) => ({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'classification-probe', version: '1.0.0' }, + _meta: meta + } + }); + + const requestMetaEnvelope = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {} + }; + + it('initializes a session-era client that sends an empty _meta and no header', async () => { + // The MCP Python SDK 2.x shape. Answering -32020 here is what made the + // fixture unusable as its upstream server. + const { status, payload } = await post(sessionEraInitialize({})); + + expect(payload?.error).toBeUndefined(); + expect(status).toBe(200); + expect(payload?.result?.protocolVersion).toBe('2025-11-25'); + }); + + it('initializes a session-era client whose _meta carries a progress token, with the session-era header', async () => { + // `_meta` is base-shape in that revision; the header names the same one. + const { status, payload } = await post( + sessionEraInitialize({ progressToken: 'probe-1' }), + { 'MCP-Protocol-Version': '2025-11-25' } + ); + + expect(payload?.error).toBeUndefined(); + expect(status).toBe(200); + expect(payload?.result?.protocolVersion).toBe('2025-11-25'); + }); + + it('still answers -32020 when a request-meta-era request omits the protocol header', async () => { + const { status, payload } = await post({ + jsonrpc: '2.0', + id: 2, + method: 'server/discover', + params: { _meta: requestMetaEnvelope } + }); + + expect(status).toBe(400); + expect(payload?.error?.code).toBe(-32020); + }); + + it('still answers -32602 when the envelope is missing its protocol version', async () => { + const { status, payload } = await post( + { + jsonrpc: '2.0', + id: 3, + method: 'server/discover', + params: { + _meta: { + 'io.modelcontextprotocol/clientCapabilities': {} + } + } + }, + { 'MCP-Protocol-Version': '2026-07-28' } + ); + + expect(status).toBe(400); + expect(payload?.error?.code).toBe(-32602); + }); + + it('serves a complete request-meta-era request', async () => { + const { status, payload } = await post( + { + jsonrpc: '2.0', + id: 4, + method: 'server/discover', + params: { _meta: requestMetaEnvelope } + }, + { 'MCP-Protocol-Version': '2026-07-28' } + ); + + expect(status).toBe(200); + expect(payload?.error).toBeUndefined(); + expect(payload?.result?.supportedVersions).toContain('2026-07-28'); + }); +});