From fbcd84209473794b82afa20739af40643ff799d8 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 11:18:17 +0000 Subject: [PATCH 1/8] test(e2e): add dual-era entry and stdio serving scenarios to the version matrix New requirement rows and scenario bodies for the dual-era serving stack: - hosting-entry.test.ts hosts createMcpHandler's node face on a real node:http listener and drives one ctx-taking factory per the spec-version axis: a plain client through the legacy 'stateless' slot on 2025-11-25 and the auto-negotiating client (plus a pin arm) on 2026-07-28, the strict (legacy-omitted) rejection of a 2025-shaped initialize with the supported list, and notification POSTs answering 202 on both legs. - stdio-dual-era.test.ts spawns a dual-era (eraSupport: 'dual-era') stdio fixture as a real child process and drives it with a plain 2025 client or the auto-negotiating client per the spec-version axis. The new requirements are transport-restricted (with notes) so each registers one cell per applicable spec version; no existing requirement or scenario changes. --- test/e2e/fixtures/dual-era-stdio-server.ts | 29 ++++ test/e2e/requirements.ts | 40 +++++ test/e2e/scenarios/hosting-entry.test.ts | 186 +++++++++++++++++++++ test/e2e/scenarios/stdio-dual-era.test.ts | 88 ++++++++++ 4 files changed, 343 insertions(+) create mode 100644 test/e2e/fixtures/dual-era-stdio-server.ts create mode 100644 test/e2e/scenarios/hosting-entry.test.ts create mode 100644 test/e2e/scenarios/stdio-dual-era.test.ts diff --git a/test/e2e/fixtures/dual-era-stdio-server.ts b/test/e2e/fixtures/dual-era-stdio-server.ts new file mode 100644 index 0000000000..b99b9763a9 --- /dev/null +++ b/test/e2e/fixtures/dual-era-stdio-server.ts @@ -0,0 +1,29 @@ +/** + * Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells. + * + * `eraSupport: 'dual-era'` is the single declared act on an otherwise ordinary + * hand-constructed McpServer connected to the unchanged StdioServerTransport. + * Spawned as a real child process (via tsx) by + * test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF. + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +const server = new McpServer( + { name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, + { capabilities: { tools: {} }, eraSupport: 'dual-era' } +); + +server.registerTool( + 'echo', + { + description: 'Echoes the input text back as a text content block.', + inputSchema: z.object({ text: z.string() }) + }, + ({ text }) => ({ content: [{ type: 'text', text }] }) +); + +await server.connect(new StdioServerTransport()); +process.stderr.write('[dual-era-stdio-server] ready\n'); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 7f5d68077e..cb4a2d9417 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2195,6 +2195,46 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. The allowed-host control asserts initialize semantics per spec version: a 2026-era request is answered with the latest legacy version, since 2026-era revisions are never negotiated via initialize.' }, + + // v2 features: dual-era serving (createMcpHandler entry, eraSupport stdio, result stamping) + + 'typescript:hosting:entry:dual-era-one-factory': { + source: 'sdk', + behavior: + 'createMcpHandler serves one ctx-taking factory to both protocol eras on one endpoint: with the legacy "stateless" slot configured, a plain client is served per request via initialize, tools/list and tools/call on the 2025 era, and an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize) and gets tools/call served with the per-request _meta envelope.', + transports: ['streamableHttp'], + note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which era the cell drives.' + }, + 'typescript:hosting:entry:pin-negotiation': { + source: 'sdk', + behavior: + 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, + 'typescript:hosting:entry:strict-rejects-legacy': { + source: 'sdk', + behavior: + 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + }, + 'typescript:hosting:entry:notification-202': { + source: 'sdk', + behavior: + 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', + transports: ['streamableHttp'], + note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides.' + }, + 'typescript:transport:stdio:dual-era-serving': { + source: 'sdk', + behavior: + 'A hand-constructed stdio server declaring eraSupport "dual-era" (transport line unchanged) serves a plain 2025 client via initialize and an auto-negotiating client on 2026-07-28 via server/discover, over a real child-process pipe.', + transports: ['stdio'], + note: 'Dual-era stdio serving is exercised against a real spawned child process (fixtures/dual-era-stdio-server.ts), so the matrix transport arg is ignored and the requirement lists stdio only; the spec-version axis selects which client drives the cell.' + }, 'custom-methods:server-handler:roundtrip': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts new file mode 100644 index 0000000000..3a7d397855 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -0,0 +1,186 @@ +/** + * Self-contained test bodies for the dual-era HTTP entry (`createMcpHandler`). + * + * Unlike most scenario areas these do not use `wire()`: every body hosts the + * handler's `node` face on a real `node:http` listener (the same wiring as + * `test/integration/test/server/createMcpHandler.test.ts`) and drives it with + * real SDK clients or plain fetch. The requirements therefore restrict the + * matrix transport axis to a single HTTP transport, and the spec-version axis + * selects which era a cell drives where the requirement spans both. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { + CallToolResult, + CreateMcpHandlerOptions, + McpHttpHandler, + McpRequestContext, + McpServerFactory +} from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The per-request `_meta` envelope every 2026-era request carries (attached explicitly until automatic emission lands client-side). */ +function modernEnvelope(name = 'e2e-entry-client') { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name, version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** One ctx-taking factory backing every cell: the era only shows up in the tool output so tests can see which leg served the call. */ +function greetFactory(ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (${ctx.era})` }] + })); + return server; +} + +interface Endpoint extends AsyncDisposable { + baseUrl: URL; + handler: McpHttpHandler; +} + +/** Hosts the handler's node face on a real node:http listener bound to an ephemeral port. */ +async function startEndpoint(factory: McpServerFactory, options?: CreateMcpHandlerOptions): Promise { + const handler = createMcpHandler(factory, options); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { + baseUrl, + handler, + [Symbol.asyncDispose]: async () => { + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } + }; +} + +verifies('typescript:hosting:entry:dual-era-one-factory', async ({ protocolVersion }: TestArgs) => { + await using endpoint = await startEndpoint(greetFactory, { legacy: 'stateless' }); + + if (protocolVersion === LEGACY) { + // 2025-era leg: a plain client is served per request through the + // legacy 'stateless' slot — initialize → tools/list → tools/call. + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); + try { + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); + } finally { + await client.close(); + } + return; + } + + // 2026-era leg: the auto-negotiating client reaches 2026-07-28 (via + // server/discover) and tools/call is served with the per-request envelope. + const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); + try { + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const result = (await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope('auto-client') } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:pin-negotiation', async (_args: TestArgs) => { + // Strict endpoint (no legacy slot): the pinned client never needs one. + await using endpoint = await startEndpoint(greetFactory); + + const bodies: string[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') bodies.push(init.body); + return fetch(input, init); + }; + + const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch })); + try { + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + // No initialize was ever put on the wire; the first request is the discover probe. + expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); + expect(bodies[0]).toContain('server/discover'); + + const result = (await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'pinned' }, _meta: modernEnvelope('pin-client') } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:strict-rejects-legacy', async (_args: TestArgs) => { + // legacy omitted → modern-only strict: no silent 2025 serving. + await using endpoint = await startEndpoint(greetFactory); + + // The documented strict cell over plain HTTP: a 2025-shaped initialize is + // answered with the unsupported-protocol-version error naming the + // supported modern revisions (the numeric code is not pinned here). + const response = await fetch(endpoint.baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'plain-2025-client', version: '1.0.0' } } + }) + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string; data?: { supported?: string[] } } }; + expect(body.error.message).toMatch(/unsupported protocol version/i); + expect(body.error.data?.supported).toContain(MODERN); + + // The plain SDK client sees the same rejection at connect time. + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); + await client.close().catch(() => {}); +}); + +verifies('typescript:hosting:entry:notification-202', async ({ protocolVersion }: TestArgs) => { + await using endpoint = await startEndpoint(greetFactory, { legacy: 'stateless' }); + + // 2025 leg: an envelope-less notification rides the legacy stateless slot. + // 2026 leg: the notification carries the per-request envelope and a method + // the 2026-07-28 registry defines. + const notification = + protocolVersion === LEGACY + ? { jsonrpc: '2.0', method: 'notifications/initialized' } + : { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'never-issued', reason: 'probe', _meta: modernEnvelope() } + }; + + const response = await fetch(endpoint.baseUrl, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify(notification) + }); + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); +}); diff --git a/test/e2e/scenarios/stdio-dual-era.test.ts b/test/e2e/scenarios/stdio-dual-era.test.ts new file mode 100644 index 0000000000..46a2406ea1 --- /dev/null +++ b/test/e2e/scenarios/stdio-dual-era.test.ts @@ -0,0 +1,88 @@ +/** + * Self-contained test bodies for dual-era stdio serving. + * + * Like the other transport:stdio scenarios these do not use `wire()`: each + * body spawns the dual-era fixture server in + * `fixtures/dual-era-stdio-server.ts` (eraSupport: 'dual-era', unchanged + * StdioServerTransport) as a real child process via {@link StdioClientTransport}. + * The matrix `transport` arg is ignored (the requirement lists + * `transports: ['stdio']`); the spec-version axis selects which client drives + * the cell — a plain 2025 client over `initialize`, or the auto-negotiating + * client reaching 2026-07-28 over `server/discover` on the same kind of pipe. + */ + +import { fileURLToPath } from 'node:url'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable dual-era fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +const MODERN = '2026-07-28'; + +verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT + }); + + if (protocolVersion === '2025-11-25') { + // Legacy leg: a plain 2025 client is served via initialize, exactly as + // against an undeclared server. + const client = new Client({ name: 'plain-2025-client', version: '0' }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy leg' } }); + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'legacy leg' }]); + } finally { + await client.close(); + await transport.close(); + } + return; + } + + // Modern leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover on the pipe (no initialize is ever written) and + // tools/call round-trips with the per-request envelope. + const sentMethods: string[] = []; + const originalSend = transport.send.bind(transport); + transport.send = async message => { + if ('method' in message) sentMethods.push(message.method); + return originalSend(message); + }; + + const client = new Client({ name: 'auto-client', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + try { + await client.connect(transport); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + expect(sentMethods).not.toContain('initialize'); + expect(sentMethods[0]).toBe('server/discover'); + + const envelope = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'auto-client', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + const result = (await client.request({ + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'modern leg' }, _meta: envelope } + })) as CallToolResult; + expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]); + } finally { + await client.close(); + await transport.close(); + } +}); From ddfbebabec2a249ed90a9fd39c429f59be907495 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 11:18:54 +0000 Subject: [PATCH 2/8] test(e2e): assert result stamping and cache-field suppression over the entry Two new requirement rows and a scenario file driving a fully cache-hint configured factory through createMcpHandler over real HTTP: - the 2026-07-28 cell does typed tools/list and resources/read round trips with the negotiating client and asserts on the captured wire bytes that the results carry resultType 'complete' and the required ttlMs/cacheScope fields, with the per-resource cacheHint visibly winning over the per-operation hint (documented precedence); - the 2025-11-25 cell serves the same factory to a plain client through the legacy stateless slot and asserts none of that vocabulary appears anywhere in the response bytes. --- test/e2e/requirements.ts | 16 ++ .../scenarios/hosting-entry-stamping.test.ts | 197 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 test/e2e/scenarios/hosting-entry-stamping.test.ts diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index cb4a2d9417..2f2b591bc3 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2228,6 +2228,22 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides.' }, + 'typescript:hosting:entry:modern-cacheable-stamping': { + source: 'sdk', + behavior: + 'Typed tools/list and resources/read round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with configured cache hints winning per the documented precedence (per-resource cacheHint over the per-operation cacheHints entry over the ttlMs 0 / cacheScope private defaults).', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, + 'typescript:hosting:entry:legacy-cacheable-suppression': { + source: 'sdk', + behavior: + 'A factory with every cache-hint author configured (per-operation cacheHints and a per-resource cacheHint), served to a plain 2025 client through the legacy stateless slot of a createMcpHandler endpoint, answers tools/list and resources/read with no resultType, ttlMs, cacheScope or cacheHint vocabulary anywhere in the response bytes.', + transports: ['streamableHttp'], + removedInSpecVersion: '2026-07-28', + note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell.' + }, 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts new file mode 100644 index 0000000000..8baa63b770 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -0,0 +1,197 @@ +/** + * Result stamping and cache-field fill, end to end over the dual-era HTTP + * entry (`createMcpHandler`), with the era boundary asserted on the wire: + * + * - 2026-07-28 cells: typed tools/list and resources/read round trips through + * the negotiating client succeed, and the captured wire results carry + * `resultType: 'complete'` plus the required `ttlMs`/`cacheScope` fields, + * with configured cache hints visibly winning per the documented precedence + * (per-resource hint over the per-operation hint over the + * `{ ttlMs: 0, cacheScope: 'private' }` defaults). + * - 2025-11-25 cells: the same fully cache-hint-configured factory served to a + * plain client through the legacy stateless slot answers the same calls with + * none of that vocabulary anywhere in the response bytes. + * + * Like hosting-entry.test.ts these bodies host the handler's node face on a + * real node:http listener and do not use `wire()`. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; +const MODERN = '2026-07-28'; + +/** The cache-field vocabulary that must never appear on a 2025-era response. */ +const CACHE_VOCABULARY = ['"resultType"', '"ttlMs"', '"cacheScope"', '"cacheHint"'] as const; + +function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'e2e-stamping-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * One ctx-taking factory with every cache-hint author configured: + * - a per-operation hint for tools/list (the funnel-built result with no other author), + * - a per-operation hint for resources/read AND a per-resource hint on the + * registered resource, so the documented precedence (per-resource wins) is + * observable on the wire. + */ +function cacheConfiguredFactory(_ctx: McpRequestContext): McpServer { + const server = new McpServer( + { name: 'e2e-entry-cache', version: '1.0.0' }, + { + capabilities: { tools: {}, resources: {} }, + cacheHints: { + 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, + 'resources/read': { ttlMs: 90_000, cacheScope: 'public' } + } + } + ); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name}` }] + })); + server.registerResource('note', 'memo://note', { cacheHint: { ttlMs: 12_000, cacheScope: 'private' } }, async uri => ({ + contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'cached note' }] + })); + return server; +} + +interface Endpoint extends AsyncDisposable { + baseUrl: URL; + handler: McpHttpHandler; +} + +async function startEndpoint(): Promise { + const handler = createMcpHandler(cacheConfiguredFactory, { legacy: 'stateless' }); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { + baseUrl, + handler, + [Symbol.asyncDispose]: async () => { + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } + }; +} + +/** Records every HTTP response body the client receives so wire bytes can be asserted alongside the typed results. */ +function recordingFetch(responseBodies: string[]): typeof fetch { + return async (input, init) => { + const response = await fetch(input, init); + responseBodies.push(await response.clone().text()); + return response; + }; +} + +/** Parses a captured response body (plain JSON or SSE-framed) into its JSON-RPC messages. */ +function jsonRpcMessagesFrom(text: string): Array> { + if (text.trim() === '') return []; + if (text.includes('data: ')) { + return text + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice(6)) as Record); + } + try { + const parsed = JSON.parse(text) as Record | Array>; + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return []; + } +} + +/** Finds the wire result of the response message whose result carries the given key. */ +function wireResultWith(responseBodies: string[], key: string): Record | undefined { + for (const body of responseBodies) { + for (const message of jsonRpcMessagesFrom(body)) { + const result = message.result as Record | undefined; + if (result && key in result) return result; + } + } + return undefined; +} + +verifies('typescript:hosting:entry:modern-cacheable-stamping', async (_args: TestArgs) => { + await using endpoint = await startEndpoint(); + + const responseBodies: string[] = []; + const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch(responseBodies) })); + + try { + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Typed round trips (the 2026 wire result schemas require the cache + // fields, so a successful decode is itself part of the assertion). + const list = (await client.request({ method: 'tools/list', params: { _meta: modernEnvelope() } })) as { + tools: Array<{ name: string }>; + }; + expect(list.tools.map(tool => tool.name)).toEqual(['greet']); + + const read = (await client.request({ + method: 'resources/read', + params: { uri: 'memo://note', _meta: modernEnvelope() } + })) as { contents: Array<{ text?: string }> }; + expect(read.contents[0]?.text).toBe('cached note'); + + // Wire-level: resultType is stamped and the cache fields carry the + // configured hints. tools/list has only the per-operation author; + // resources/read shows the per-resource hint winning over the + // per-operation hint (documented precedence). + const listResult = wireResultWith(responseBodies, 'tools'); + expect(listResult).toBeDefined(); + expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + + const readResult = wireResultWith(responseBodies, 'contents'); + expect(readResult).toBeDefined(); + expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:legacy-cacheable-suppression', async (_args: TestArgs) => { + await using endpoint = await startEndpoint(); + + const responseBodies: string[] = []; + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch(responseBodies) })); + + try { + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + + // The same calls, typed, on the 2025 leg (served through the legacy stateless slot). + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + // None of the 2026 cache vocabulary appears anywhere in the bytes of + // any response of this conversation, even though every cache-hint + // author is configured on the factory. + const conversation = responseBodies.join('\n'); + expect(conversation).toContain('"tools"'); + expect(conversation).toContain('"contents"'); + for (const term of CACHE_VOCABULARY) { + expect(conversation).not.toContain(term); + } + } finally { + await client.close(); + } +}); From c79f25c942440eda6a0e01f072d7b295221ffbac Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 12:53:05 +0000 Subject: [PATCH 3/8] test(e2e): tighten the dual-era entry assertions to their requirement texts - dual-era-one-factory, modern cell: record the request bodies and assert the never-initialize clause on the wire (negotiation rides server/discover only). - strict-rejects-legacy: close the probing client in a finally block. - modern-cacheable-stamping: add a resources/list round trip so a third precedence rung (defaults fill for a result with no configured author) is observed on the wire, and align the requirement text with what the cell observes; note that the handler-returned top rung stays unit-pinned. - notification-202: note in the manifest that the cells pin the HTTP contract only; instance delivery is pinned at unit level. --- test/e2e/requirements.ts | 6 ++-- .../scenarios/hosting-entry-stamping.test.ts | 32 +++++++++++++------ test/e2e/scenarios/hosting-entry.test.ts | 26 ++++++++++++--- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 2f2b591bc3..46a3f5d756 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2226,15 +2226,15 @@ export const REQUIREMENTS: Record = { behavior: 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', transports: ['streamableHttp'], - note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides.' + note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides. The cells pin the HTTP contract only (status code and empty body); delivery of the notification to the per-request server instance is pinned at unit level.' }, 'typescript:hosting:entry:modern-cacheable-stamping': { source: 'sdk', behavior: - 'Typed tools/list and resources/read round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with configured cache hints winning per the documented precedence (per-resource cacheHint over the per-operation cacheHints entry over the ttlMs 0 / cacheScope private defaults).', + 'Typed tools/list, resources/read and resources/list round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with the configured-hint precedence observable on the wire: the per-resource cacheHint wins over the per-operation cacheHints entry (resources/read), a per-operation hint wins over the defaults (tools/list), and a result with no configured author is filled with the ttlMs 0 / cacheScope private defaults (resources/list).', transports: ['streamableHttp'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis. The top precedence rung — a handler-returned ttlMs/cacheScope value winning over every configured hint — is pinned at unit level and not exercised here.' }, 'typescript:hosting:entry:legacy-cacheable-suppression': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts index 8baa63b770..758811603a 100644 --- a/test/e2e/scenarios/hosting-entry-stamping.test.ts +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -2,12 +2,16 @@ * Result stamping and cache-field fill, end to end over the dual-era HTTP * entry (`createMcpHandler`), with the era boundary asserted on the wire: * - * - 2026-07-28 cells: typed tools/list and resources/read round trips through - * the negotiating client succeed, and the captured wire results carry - * `resultType: 'complete'` plus the required `ttlMs`/`cacheScope` fields, - * with configured cache hints visibly winning per the documented precedence - * (per-resource hint over the per-operation hint over the - * `{ ttlMs: 0, cacheScope: 'private' }` defaults). + * - 2026-07-28 cells: typed tools/list, resources/read and resources/list + * round trips through the negotiating client succeed, and the captured wire + * results carry `resultType: 'complete'` plus the required + * `ttlMs`/`cacheScope` fields, with three rungs of the documented precedence + * observable on the wire: the per-resource hint wins over the per-operation + * hint (resources/read), a per-operation hint wins over the defaults + * (tools/list), and a result with no configured author is filled with the + * `{ ttlMs: 0, cacheScope: 'private' }` defaults (resources/list). The top + * rung — a handler-returned value winning over every configured hint — is + * pinned at unit level (encodeContract), not here. * - 2025-11-25 cells: the same fully cache-hint-configured factory served to a * plain client through the legacy stateless slot answers the same calls with * none of that vocabulary anywhere in the response bytes. @@ -149,10 +153,16 @@ verifies('typescript:hosting:entry:modern-cacheable-stamping', async (_args: Tes })) as { contents: Array<{ text?: string }> }; expect(read.contents[0]?.text).toBe('cached note'); + const resourceList = (await client.request({ method: 'resources/list', params: { _meta: modernEnvelope() } })) as { + resources: Array<{ uri: string }>; + }; + expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']); + // Wire-level: resultType is stamped and the cache fields carry the - // configured hints. tools/list has only the per-operation author; - // resources/read shows the per-resource hint winning over the - // per-operation hint (documented precedence). + // configured hints. tools/list has only the per-operation author (its + // hint wins over the defaults); resources/read shows the per-resource + // hint winning over the per-operation hint; resources/list has no + // configured author at all and is filled with the documented defaults. const listResult = wireResultWith(responseBodies, 'tools'); expect(listResult).toBeDefined(); expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); @@ -160,6 +170,10 @@ verifies('typescript:hosting:entry:modern-cacheable-stamping', async (_args: Tes const readResult = wireResultWith(responseBodies, 'contents'); expect(readResult).toBeDefined(); expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + + const resourceListResult = wireResultWith(responseBodies, 'resources'); + expect(resourceListResult).toBeDefined(); + expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); } finally { await client.close(); } diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 3a7d397855..1e2696d721 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -89,12 +89,23 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ protocolVersi return; } - // 2026-era leg: the auto-negotiating client reaches 2026-07-28 (via - // server/discover) and tools/call is served with the per-request envelope. + // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via + // server/discover — never initialize — and tools/call is served with the + // per-request envelope. + const requestBodies: string[] = []; + const recordingFetch: typeof fetch = async (input, init) => { + if (typeof init?.body === 'string') requestBodies.push(init.body); + return fetch(input, init); + }; const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); + await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch })); try { expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + // The "(never initialize)" clause of the requirement, asserted on the + // recorded wire traffic: no request body ever carried an initialize, + // and the negotiation rode server/discover. + expect(requestBodies.some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies.some(body => body.includes('server/discover'))).toBe(true); const result = (await client.request({ method: 'tools/call', params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope('auto-client') } @@ -157,8 +168,13 @@ verifies('typescript:hosting:entry:strict-rejects-legacy', async (_args: TestArg // The plain SDK client sees the same rejection at connect time. const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); - await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); - await client.close().catch(() => {}); + try { + await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow( + /Unsupported protocol version|400/ + ); + } finally { + await client.close().catch(() => {}); + } }); verifies('typescript:hosting:entry:notification-202', async ({ protocolVersion }: TestArgs) => { From 33d5db28f353e89dcd974e11fbcfa12633f3d3d6 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 13:02:15 +0000 Subject: [PATCH 4/8] test(e2e): cover sessionful BYO legacy serving and modern streaming through the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new requirement rows and two scenario files on real node:http listeners: - byo-sessionful-legacy: a real per-session streamable HTTP wiring passed as the createMcpHandler legacy slot serves the full 2025-era session lifecycle through the entry — initialize issues an Mcp-Session-Id, a follow-up POST is served on the session, the body-less GET opens the standalone SSE stream, DELETE tears the session down and a request on the dead session answers 404. Exchanges are recorded as they leave the slot, pinning the entry's GET/DELETE routing and untouched forwarding. - modern-lazy-sse-upgrade: on the default response mode a quiet handler is answered as a single JSON body and a handler emitting mid-call notifications upgrades to SSE, with frame order asserted (notifications first, terminal result last) on the captured wire bytes. - modern-response-mode: responseMode 'sse' streams even with no mid-call output; responseMode 'json' answers a plain JSON body and drops the mid-call notifications. --- test/e2e/requirements.ts | 24 +++ .../scenarios/hosting-entry-session.test.ts | 155 ++++++++++++++ .../scenarios/hosting-entry-streaming.test.ts | 197 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 test/e2e/scenarios/hosting-entry-session.test.ts create mode 100644 test/e2e/scenarios/hosting-entry-streaming.test.ts diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 46a3f5d756..3bfb8a19b3 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2244,6 +2244,30 @@ export const REQUIREMENTS: Record = { removedInSpecVersion: '2026-07-28', note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell.' }, + 'typescript:hosting:entry:byo-sessionful-legacy': { + source: 'sdk', + behavior: + 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', + transports: ['streamableHttp'], + removedInSpecVersion: '2026-07-28', + note: 'The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell. It pins the entry routing of body-less GET and DELETE to the legacy slot and byte-untouched forwarding to the bring-your-own handler.' + }, + 'typescript:hosting:entry:modern-lazy-sse-upgrade': { + source: 'sdk', + behavior: + 'On the default response mode, a modern (2026-07-28) request exchange over a createMcpHandler endpoint is answered as a single JSON body when the handler emits nothing before its result, and upgrades to an SSE stream when the handler emits related notifications mid-call: the response content-type becomes text/event-stream and the frames carry the notifications in emission order with the terminal result as the last frame.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener and drives it with the negotiating client over a recording fetch, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, + 'typescript:hosting:entry:modern-response-mode': { + source: 'sdk', + behavior: + 'The createMcpHandler responseMode option shapes modern (2026-07-28) request exchanges end to end: "sse" answers over an SSE stream even when the handler emits nothing before its result, and "json" answers with a single JSON body whose only payload is the terminal result — mid-call notifications are dropped, not buffered.', + transports: ['streamableHttp'], + addedInSpecVersion: '2026-07-28', + note: 'The entry is an HTTP-only surface; the cell hosts one endpoint per responseMode value on a real node:http listener and drives both with the negotiating client over a recording fetch, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + }, 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts new file mode 100644 index 0000000000..afc8e7df30 --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -0,0 +1,155 @@ +/** + * Sessionful 2025-era serving through the dual-era HTTP entry's + * bring-your-own legacy slot. + * + * The legacy slot value is a real sessionful wiring — one + * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by + * the Mcp-Session-Id the transport itself issues (the documented sessionful + * hosting pattern) — and a plain 2025 SDK client drives the full session + * lifecycle through `createMcpHandler` over a real socket: initialize issues a + * session id, a follow-up POST is served on that session, the body-less GET + * opens the standalone SSE stream, and DELETE tears the session down. Every + * exchange the slot serves is recorded as it leaves the wiring, so the entry's + * routing of GET/DELETE (no envelope, no body → legacy slot) and its + * byte-untouched forwarding to the bring-your-own handler are pinned directly. + * + * Like hosting-entry.test.ts these bodies host the handler's node face on a + * real node:http listener and do not use `wire()`. + */ +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const LEGACY = '2025-11-25'; + +/** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ +function modernFactory(_ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (modern)` }] + })); + return server; +} + +verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArgs) => { + // The documented sessionful wiring, passed as the bring-your-own legacy + // slot value: a fresh transport per initialize, kept in a map keyed by the + // Mcp-Session-Id it issues; later requests are routed by that header. + const sessions = new Map(); + const closedSessions: string[] = []; + const sessionServers: McpServer[] = []; + + async function routeSessionRequest(request: Request, options?: McpHandlerRequestOptions): Promise { + const sessionId = request.headers.get('mcp-session-id'); + if (sessionId !== null) { + const existing = sessions.get(sessionId); + if (existing !== undefined) return existing.handleRequest(request, options); + // A request for a session this wiring no longer (or never) knew — + // the documented sessionful pattern answers 404. + return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: randomUUID, + onsessioninitialized: id => void sessions.set(id, transport), + onsessionclosed: id => { + closedSessions.push(id); + sessions.delete(id); + } + }); + const server = new McpServer({ name: 'byo-session-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ + content: [{ type: 'text', text: `hello ${name} (byo session)` }] + })); + sessionServers.push(server); + await server.connect(transport); + return transport.handleRequest(request, options); + } + + // Every exchange the entry forwards to the bring-your-own slot, recorded + // as it leaves the wiring: this is what proves the GET/DELETE routing. + const slotExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async (request, options) => { + const response = await routeSessionRequest(request, options); + slotExchanges.push({ + method: request.method.toUpperCase(), + status: response.status, + contentType: response.headers.get('content-type') ?? '' + }); + return response; + }; + + const handler = createMcpHandler(modernFactory, { legacy: sessionfulLegacy }); + const httpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + + const clientTransport = new StreamableHTTPClientTransport(baseUrl); + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + try { + // initialize → the bring-your-own transport issues an Mcp-Session-Id. + // (The stateless slot never issues one, so a defined session id alone + // proves the request reached the bring-your-own wiring.) + await client.connect(clientTransport); + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const sessionId = clientTransport.sessionId; + expect(sessionId).toBeDefined(); + expect(sessions.has(sessionId!)).toBe(true); + + // Follow-up POST on the session: served by the same per-session instance. + const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (byo session)' }]); + expect(clientTransport.sessionId).toBe(sessionId); + + // GET route: the client opens its standalone SSE stream after + // initialization; the entry routes the body-less GET (no envelope) to + // the legacy slot, which answers it with the stream. + await vi.waitFor( + () => { + const get = slotExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy slot yet'); + expect(get.status).toBe(200); + expect(get.contentType).toContain('text/event-stream'); + }, + { timeout: 5000, interval: 50 } + ); + + // DELETE route: terminating the session goes through the entry to the + // bring-your-own transport, which tears the session down. + await clientTransport.terminateSession(); + expect(closedSessions).toEqual([sessionId]); + const deleteExchange = slotExchanges.find(exchange => exchange.method === 'DELETE'); + expect(deleteExchange?.status).toBe(200); + + // Stop the client before probing the dead session so its standalone + // stream cannot reconnect underneath the assertion. + await client.close(); + + // The dead session is gone: a POST carrying its id is answered 404 by + // the bring-your-own wiring, not silently re-served by anything else. + const stale = await fetch(baseUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId!, + 'mcp-protocol-version': LEGACY + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'tools/list', params: {} }) + }); + expect(stale.status).toBe(404); + await stale.text(); + } finally { + await client.close().catch(() => {}); + for (const server of sessionServers) await server.close().catch(() => {}); + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } +}); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts new file mode 100644 index 0000000000..fda8a3a98b --- /dev/null +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -0,0 +1,197 @@ +/** + * Modern-era (2026-07-28) response streaming through the dual-era HTTP entry, + * observed on a real socket: + * + * - default response mode: a handler that emits nothing before its result is + * answered as a single JSON body; a handler that emits related notifications + * mid-call upgrades the response to an SSE stream (content-type + * text/event-stream, notifications framed in emission order, terminal result + * last); + * - `responseMode: 'sse'` always streams, even with no mid-call output; + * - `responseMode: 'json'` never streams and drops mid-call notifications — + * only the terminal result is delivered. + * + * Every body hosts the handler's node face on a real node:http listener and + * drives it with the auto-negotiating client over a recording fetch, so the + * typed result and the raw wire bytes are asserted side by side. Like + * hosting-entry.test.ts these bodies do not use `wire()`. + */ +import type { Server as HttpServer } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const MODERN = '2026-07-28'; + +/** The per-request `_meta` envelope every 2026-era request carries (attached explicitly until automatic emission lands client-side). */ +function modernEnvelope() { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'e2e-streaming-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * One factory with a quiet tool (no streamed output) and a chatty tool (two + * logging notifications emitted before its result), so the lazy upgrade and + * both forced response modes are observable per call. + */ +function streamingFactory(_ctx: McpRequestContext): McpServer { + const server = new McpServer({ name: 'e2e-entry-streaming', version: '1.0.0' }, { capabilities: { tools: {}, logging: {} } }); + server.registerTool('quiet', { inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'quiet result' }] + })); + server.registerTool('chatty', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'first' } }); + await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'second' } }); + return { content: [{ type: 'text', text: 'chatty result' }] }; + }); + return server; +} + +interface Endpoint extends AsyncDisposable { + baseUrl: URL; + handler: McpHttpHandler; +} + +/** Hosts the handler's node face on a real node:http listener bound to an ephemeral port. */ +async function startEndpoint(options?: CreateMcpHandlerOptions): Promise { + const handler = createMcpHandler(streamingFactory, options); + const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const baseUrl = await listenOnRandomPort(httpServer); + return { + baseUrl, + handler, + [Symbol.asyncDispose]: async () => { + await handler.close(); + await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); + } + }; +} + +interface RecordedResponse { + status: number; + contentType: string; + body: string; +} + +/** Records every HTTP response (status, content-type, raw body bytes) the client receives. */ +function recordingFetch(responses: RecordedResponse[]): typeof fetch { + return async (input, init) => { + const response = await fetch(input, init); + responses.push({ + status: response.status, + contentType: response.headers.get('content-type') ?? '', + body: await response.clone().text() + }); + return response; + }; +} + +/** The `data:` payloads of an SSE-framed body, parsed, in frame order. */ +function sseDataFrames(body: string): Array> { + return body + .split('\n') + .filter(line => line.startsWith('data: ')) + .map(line => JSON.parse(line.slice('data: '.length)) as Record); +} + +async function connectAutoClient(baseUrl: URL, responses: RecordedResponse[]): Promise { + const client = new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: recordingFetch(responses) })); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + return client; +} + +function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { + return client.request({ + method: 'tools/call', + params: { name, arguments: {}, _meta: modernEnvelope() } + }) as Promise; +} + +verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async (_args: TestArgs) => { + await using endpoint = await startEndpoint(); + + const responses: RecordedResponse[] = []; + const client = await connectAutoClient(endpoint.baseUrl, responses); + try { + // Quiet handler: nothing emitted before the result → a single JSON body. + const quiet = await callTool(client, 'quiet'); + expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]); + const quietResponse = responses.find(response => response.body.includes('quiet result')); + expect(quietResponse).toBeDefined(); + expect(quietResponse!.status).toBe(200); + expect(quietResponse!.contentType).toContain('application/json'); + + // Chatty handler: the first related notification upgrades the exchange + // to SSE — notifications framed in order, terminal result last. + const chatty = await callTool(client, 'chatty'); + expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]); + const chattyResponse = responses.find(response => response.body.includes('chatty result')); + expect(chattyResponse).toBeDefined(); + expect(chattyResponse!.status).toBe(200); + expect(chattyResponse!.contentType).toContain('text/event-stream'); + + const frames = sseDataFrames(chattyResponse!.body); + expect(frames).toHaveLength(3); + expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } }); + expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } }); + expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } }); + } finally { + await client.close(); + } +}); + +verifies('typescript:hosting:entry:modern-response-mode', async (_args: TestArgs) => { + // One endpoint per responseMode value, both backed by the same factory. + await using sseEndpoint = await startEndpoint({ responseMode: 'sse' }); + await using jsonEndpoint = await startEndpoint({ responseMode: 'json' }); + + // responseMode 'sse': even a handler that emits nothing streams its result. + { + const responses: RecordedResponse[] = []; + const client = await connectAutoClient(sseEndpoint.baseUrl, responses); + try { + const result = await callTool(client, 'quiet'); + expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]); + const response = responses.find(candidate => candidate.body.includes('quiet result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('text/event-stream'); + const frames = sseDataFrames(response!.body); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } }); + } finally { + await client.close(); + } + } + + // responseMode 'json': mid-call notifications are dropped — the response + // is a plain JSON body whose only payload is the terminal result. + { + const responses: RecordedResponse[] = []; + const client = await connectAutoClient(jsonEndpoint.baseUrl, responses); + try { + const result = await callTool(client, 'chatty'); + expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]); + const response = responses.find(candidate => candidate.body.includes('chatty result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('application/json'); + expect(response!.body).not.toContain('notifications/message'); + } finally { + await client.close(); + } + } +}); From a2545ad37d5078236dc6a16627729c37d0cdd1de Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 14:37:59 +0000 Subject: [PATCH 5/8] test(e2e): sharpen the BYO-session and never-initialize assertions - byo-sessionful-legacy: assert that the dead-session 404 was produced by the bring-your-own wiring (the probe is visible in the recorded slot exchanges), and narrow the test header and manifest note to what the cell observes (method/status/content-type at the slot) instead of claiming byte-untouched forwarding. - dual-era-one-factory and pin-negotiation modern legs: re-assert that no initialize appears in the recorded request bodies after the tool call, so the no-initialize claim covers the whole conversation, not just the negotiation window. --- test/e2e/requirements.ts | 2 +- test/e2e/scenarios/hosting-entry-session.test.ts | 10 +++++++--- test/e2e/scenarios/hosting-entry.test.ts | 5 +++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 3bfb8a19b3..fc818398d6 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2250,7 +2250,7 @@ export const REQUIREMENTS: Record = { 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', transports: ['streamableHttp'], removedInSpecVersion: '2026-07-28', - note: 'The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell. It pins the entry routing of body-less GET and DELETE to the legacy slot and byte-untouched forwarding to the bring-your-own handler.' + note: 'The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted.' }, 'typescript:hosting:entry:modern-lazy-sse-upgrade': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index afc8e7df30..c1d19458c4 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -9,9 +9,10 @@ * lifecycle through `createMcpHandler` over a real socket: initialize issues a * session id, a follow-up POST is served on that session, the body-less GET * opens the standalone SSE stream, and DELETE tears the session down. Every - * exchange the slot serves is recorded as it leaves the wiring, so the entry's - * routing of GET/DELETE (no envelope, no body → legacy slot) and its - * byte-untouched forwarding to the bring-your-own handler are pinned directly. + * exchange the slot serves is recorded as it leaves the wiring (method, status, + * content-type), so the entry's routing of GET/DELETE (no envelope, no body → + * legacy slot) to the bring-your-own handler is pinned directly; byte-level + * forwarding fidelity is not asserted here. * * Like hosting-entry.test.ts these bodies host the handler's node face on a * real node:http listener and do not use `wire()`. @@ -146,6 +147,9 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArg }); expect(stale.status).toBe(404); await stale.text(); + // ...and that 404 was produced by the bring-your-own wiring (the probe + // reached the slot), not synthesized by the entry or anything in front of it. + expect(slotExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); } finally { await client.close().catch(() => {}); for (const server of sessionServers) await server.close().catch(() => {}); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index 1e2696d721..a477723f01 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -111,6 +111,9 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ protocolVersi params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope('auto-client') } })) as CallToolResult; expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + // ...and still no initialize anywhere on the wire after the tool call — + // the whole conversation rode the modern handshake. + expect(requestBodies.some(body => body.includes('"initialize"'))).toBe(false); } finally { await client.close(); } @@ -139,6 +142,8 @@ verifies('typescript:hosting:entry:pin-negotiation', async (_args: TestArgs) => params: { name: 'greet', arguments: { name: 'pinned' }, _meta: modernEnvelope('pin-client') } })) as CallToolResult; expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); + // ...and still no initialize anywhere on the wire after the tool call. + expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); } finally { await client.close(); } From 59536e61169bf39cf22621925b455c6db16e0698 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 16:42:20 +0000 Subject: [PATCH 6/8] test(e2e): add createMcpHandler entry arms (entryStateless, entryModern) to wire() The dual-era HTTP entry is hosted in process through an injected fetch like the other HTTP arms: entryStateless serves the scenario's plain client through the entry's legacy 'stateless' slot, entryModern keeps the endpoint modern-only strict and connects the client on the 2026-07-28 revision (pin-mode negotiation plus a per-request _meta envelope attached at the transport layer until the client emits it itself). Both arms record every HTTP exchange on the returned Wired.httpLog, accept createMcpHandler hosting overrides via the new entry option, and keep working with the wire sniffer and tapWire. The arms are era-fixed, so verifies() now intersects a requirement's spec-version bounds with the transport's served axis. --- test/e2e/helpers/index.ts | 194 +++++++++++++++++++++++++++++++++-- test/e2e/helpers/verifies.ts | 20 +++- test/e2e/types.ts | 87 +++++++++++++++- 3 files changed, 287 insertions(+), 14 deletions(-) diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 0fe566be8c..afd70b38a8 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -15,30 +15,93 @@ import { PassThrough } from 'node:stream'; import type { Client } from '@modelcontextprotocol/client'; import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server'; -import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { + CreateMcpHandlerOptions, + EventStore, + Implementation, + JSONRPCMessage, + McpRequestContext, + McpServer, + Server, + Transport as SdkTransport +} from '@modelcontextprotocol/server'; +import { + createMcpHandler, + InMemoryTransport, + ReadBuffer, + serializeMessage, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import type { Transport } from '../types.js'; +import type { SpecVersion, Transport } from '../types.js'; import { startLegacySseHost } from './sse-host.js'; import type { SnifferOptions } from './wire-sniffer.js'; import { sniffTransport } from './wire-sniffer.js'; export type ServerFactory = () => McpServer | Server; +/** + * A factory that optionally consumes the createMcpHandler per-request context. + * The context is only supplied on the entry arms (where the entry constructs a + * fresh instance per request); on every other arm the factory is called with no + * arguments, so declare the parameter optional. + */ +export type EntryServerFactory = (ctx?: McpRequestContext) => McpServer | Server; + +/** One HTTP exchange recorded by the entry arms (see {@linkcode Wired.httpLog}). */ +export interface RecordedHttpExchange { + /** HTTP request method (GET/POST/DELETE). */ + method: string; + /** The request body text, when one was sent as a string. */ + requestBody?: string; + /** HTTP response status. */ + status: number; + /** Response content-type header (empty string when absent). */ + contentType: string; + /** An unread clone of the HTTP response, for byte-level assertions (`await exchange.response.text()`). */ + response: Response; +} + export interface Wired extends AsyncDisposable { readonly fetch?: (url: URL | string, init?: RequestInit) => Promise; readonly url?: URL; + /** + * Every HTTP exchange the wired client performed, in order, including the + * connect-time negotiation. Recorded by the createMcpHandler entry arms + * only — scenarios on those arms use it to assert raw wire facts (request + * bodies, response status/content-type/bytes) that the typed client API + * does not expose. + */ + readonly httpLog?: readonly RecordedHttpExchange[]; } /** - * The fourth argument controls the wire-format sniffer (see wire-sniffer.ts): - * every message the client sends or receives is validated against the SDK's - * spec-anchored Zod schemas. Tests that intentionally use vendor-extension - * methods pass `{ allowCustomMethods: true }`; tests that deliberately put - * malformed MCP on the wire pass `{ strictValidation: false }`. + * The fourth argument's sniffer options control the wire-format sniffer (see + * wire-sniffer.ts): every message the client sends or receives is validated + * against the SDK's spec-anchored Zod schemas. Tests that intentionally use + * vendor-extension methods pass `{ allowCustomMethods: true }`; tests that + * deliberately put malformed MCP on the wire pass `{ strictValidation: false }`. + * `entry` overrides the hosting options of the createMcpHandler entry arms + * (ignored by every other transport). */ -export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise { +export interface WireOptions extends SnifferOptions { + /** + * createMcpHandler hosting overrides for the entry arms. Defaults: + * `{ legacy: 'stateless' }` on entryStateless (the canonical slot value) and + * modern-only strict (no legacy slot) on entryModern. `onerror` and + * `responseMode` pass through unchanged. + */ + entry?: CreateMcpHandlerOptions; +} + +export async function wire( + transport: Transport, + makeServer: ServerFactory | EntryServerFactory, + client: Client, + sniff: WireOptions = {} +): Promise { switch (transport) { case 'inMemory': { const server = makeServer(); @@ -67,6 +130,47 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie [Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {}) }; } + case 'entryStateless': + case 'entryModern': { + // The dual-era HTTP entry (`createMcpHandler`) hosted in process via an + // injected fetch, exactly like the other HTTP arms. The scenario factory + // backs the entry directly (the entry calls it once per request with its + // per-request context). `entryStateless` serves the scenario's plain + // client through the entry's `legacy: 'stateless'` slot; `entryModern` + // keeps the endpoint modern-only strict and connects the client on the + // 2026-07-28 revision (pin-mode negotiation + the per-request envelope + // stop-gap). Every HTTP exchange is recorded on `httpLog`. + const handler = createMcpHandler( + makeServer, + transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { ...sniff.entry } + ); + const url = new URL('http://in-process/mcp'); + const httpLog: RecordedHttpExchange[] = []; + const fetch = async (u: URL | string, init?: RequestInit) => { + const request = new Request(u, init); + const response = await handler.fetch(request); + httpLog.push({ + method: request.method.toUpperCase(), + ...(typeof init?.body === 'string' && { requestBody: init.body }), + status: response.status, + contentType: response.headers.get('content-type') ?? '', + response: response.clone() + }); + return response; + }; + let clientTx = new StreamableHTTPClientTransport(url, { fetch }); + if (transport === 'entryModern') { + pinModernNegotiation(client); + clientTx = attachModernEnvelope(clientTx); + } + await client.connect(sniffTransport(clientTx, 'client', sniff)); + return { + fetch, + url, + httpLog, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; + } case 'sse': { // The legacy SSE transport needs a real socket: the factory's server is hosted on the // shipped SSEServerTransport (@modelcontextprotocol/server-legacy/sse) behind a loopback @@ -212,6 +316,78 @@ export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpH }; } +// ─────────────────────────────────────────────────────────────────────────────── +// createMcpHandler entry arms (entryStateless / entryModern) — client-side shims +// ─────────────────────────────────────────────────────────────────────────────── + +/** The protocol revision the entryModern arm negotiates and claims per request. */ +const MODERN_REVISION: SpecVersion = '2026-07-28'; + +/** + * The per-request `_meta` envelope of a 2026-07-28 request, for scenario bodies + * that put raw HTTP requests on the wire (via `wired.fetch`) rather than going + * through the wired client. Typed calls through the wired client never need + * this — the entryModern arm attaches the envelope itself (see + * {@linkcode attachModernEnvelope}). + */ +export function modernEnvelopeMeta(clientInfo?: Implementation): Record { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: clientInfo ?? { name: 'e2e-entry-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; +} + +/** + * Put the (already constructed) scenario client into pinned 2026-07-28 + * negotiation. Version negotiation is a constructor-only option and the + * scenario corpus constructs era-agnostic clients, so the entryModern arm flips + * the option on the instance before `connect()` — a harness stop-gap, not a + * public API. Clients that already opted into a negotiation mode are left + * untouched (their cells deliberately exercise that mode). + */ +function pinModernNegotiation(client: Client): void { + const internals = client as unknown as { _versionNegotiation?: { mode?: unknown } }; + internals._versionNegotiation ??= { mode: { pin: MODERN_REVISION } }; +} + +/** + * The per-request `_meta` envelope stop-gap for the entryModern arm: the + * negotiating client only attaches the envelope to its `server/discover` probe + * today (automatic per-request emission is a client-side follow-up), so the + * harness re-attaches the same envelope to every later request and notification + * the scenario's typed calls put on the wire. The envelope is captured from the + * probe itself, so it always matches what the client actually claimed; messages + * that already carry a protocol-version claim (the probe, or a scenario's + * explicitly enveloped request) pass through untouched. + * + * Applied beneath the wire sniffer and `tapWire`, so recorded traffic shows the + * messages exactly as the scenario sent them while the wire carries the + * envelope the entry requires. + */ +function attachModernEnvelope(transport: T): T { + let envelope: Record | undefined; + const origSend = transport.send.bind(transport); + transport.send = async (message, opts) => { + let outbound = message; + if ('method' in message) { + const params = (message.params ?? {}) as { _meta?: Record }; + const meta = params._meta; + if (meta?.[PROTOCOL_VERSION_META_KEY] !== undefined) { + envelope ??= { + [PROTOCOL_VERSION_META_KEY]: meta[PROTOCOL_VERSION_META_KEY], + [CLIENT_INFO_META_KEY]: meta[CLIENT_INFO_META_KEY], + [CLIENT_CAPABILITIES_META_KEY]: meta[CLIENT_CAPABILITIES_META_KEY] + }; + } else if (envelope !== undefined) { + outbound = { ...message, params: { ...params, _meta: { ...envelope, ...meta } } }; + } + } + return origSend(outbound, opts); + }; + return transport; +} + // ─────────────────────────────────────────────────────────────────────────────── // In-process stdio client — TEST-ONLY // diff --git a/test/e2e/helpers/verifies.ts b/test/e2e/helpers/verifies.ts index bfcdc47216..0f2d07bdc4 100644 --- a/test/e2e/helpers/verifies.ts +++ b/test/e2e/helpers/verifies.ts @@ -18,11 +18,23 @@ import { describe, test } from 'vitest'; import { REQUIREMENTS } from '../requirements.js'; -import type { TestArgs } from '../types.js'; -import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js'; +import type { Requirement, SpecVersion, TestArgs, Transport } from '../types.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from '../types.js'; type TestBody = (args: TestArgs) => Promise; +/** Whether a requirement's `entryExclusions` keep the given entry arm out of its cells. */ +function excludedFromEntryArm(req: Requirement, transport: Transport): boolean { + if (!(ENTRY_TRANSPORTS as readonly Transport[]).includes(transport)) return false; + return (req.entryExclusions ?? []).some(x => x.arm === undefined || x.arm === transport); +} + +/** Whether a transport arm serves the given spec version (era-fixed arms serve exactly one). */ +function transportServesVersion(transport: Transport, version: SpecVersion): boolean { + const versions = TRANSPORT_SPEC_VERSIONS[transport]; + return versions === undefined || versions.includes(version); +} + export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void { const ids = Array.isArray(id) ? id : [id]; for (const rid of ids) registerOne(rid, fn, opts); @@ -33,13 +45,13 @@ function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void if (!req) throw new Error(`verifies('${id}'): unknown requirement id`); if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`); - const transports = req.transports ?? ALL_TRANSPORTS; + const transports = (req.transports ?? ALL_TRANSPORTS).filter(t => !excludedFromEntryArm(req, t)); const versions = ALL_SPEC_VERSIONS.filter( v => (req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) && (req.removedInSpecVersion === undefined || v < req.removedInSpecVersion) ); - const cells = versions.flatMap(v => transports.map(t => [t, v] as const)); + const cells = versions.flatMap(v => transports.filter(t => transportServesVersion(t, v)).map(t => [t, v] as const)); describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => { const kf = req.knownFailures?.find( diff --git a/test/e2e/types.ts b/test/e2e/types.ts index d10ab18fca..c82673e026 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -3,7 +3,19 @@ */ export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamableHttpStateless', 'sse'] as const; -export type Transport = (typeof ALL_TRANSPORTS)[number]; + +/** + * The createMcpHandler entry arms: the dual-era HTTP entry hosted in process + * (injected fetch → `handler.fetch`), one arm per slot. `entryStateless` serves + * a plain 2025-era client through the entry's `legacy: 'stateless'` slot; + * `entryModern` serves a client that negotiates the 2026-07-28 revision through + * the entry's modern (per-request envelope) path. Each arm is era-fixed, so it + * registers cells on exactly one spec-version axis (see TRANSPORT_SPEC_VERSIONS). + */ +export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const; +export type EntryTransport = (typeof ENTRY_TRANSPORTS)[number]; + +export type Transport = (typeof ALL_TRANSPORTS)[number] | EntryTransport; /** * Every spec version the manifest may reference — used for typing @@ -16,6 +28,19 @@ export type SpecVersion = (typeof KNOWN_SPEC_VERSIONS)[number]; /** The spec versions cells are registered for (the active matrix axis). */ export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies readonly SpecVersion[]; +/** + * Spec versions a transport arm can serve. Transports without an entry serve + * every spec version on the active axis; the entry arms are era-fixed (the + * `legacy: 'stateless'` slot serves only 2025-era traffic, the modern path + * serves only the 2026-07-28 revision), so each registers cells on exactly one + * axis. `verifies()` intersects this with a requirement's own spec-version + * bounds when forming cells. + */ +export const TRANSPORT_SPEC_VERSIONS: Partial> = { + entryStateless: ['2025-11-25'], + entryModern: ['2026-07-28'] +}; + /** * Arguments every test body receives. Expand with new matrix axes here so * test signatures don't churn — bodies destructure only what they use. @@ -32,6 +57,57 @@ export interface KnownFailure { note: string; } +/** + * Machine-readable reasons a requirement is excluded from the createMcpHandler + * entry arms. The exclusion list doubles as the acceptance checklist for the + * entry features that have not landed yet: when one of them lands, its + * reason's entries are the cells to re-admit. (Requirement families that the + * per-request entry structurally cannot serve at all — server→client requests, + * sessions/resumability, standalone GET streams, subscriptions — are already + * expressed through their existing `transports` restrictions and never reach + * the entry arms, so they need no annotation here.) + * + * - `requires-session` — needs a persistent connected server instance (or + * connection-level message delivery beyond one request/response exchange); + * the entry's modern path serves every request with a fresh instance. + * - `method-not-in-modern-registry` — drives a method the 2026-07-28 registry + * deletes (ping, logging/setLevel, resources/subscribe, + * notifications/roots/list_changed, …); meaningful only for `entryModern`. + * - `asserts-legacy-handshake` — asserts initialize/initialized handshake or + * initialize-based version-negotiation mechanics; the modern path negotiates + * via server/discover and never sends initialize, so the body would assert + * vacuously or fail. Meaningful only for `entryModern`. + * - `legacy-only-vocabulary` — asserts wire vocabulary or advertisement flags + * the 2026-07-28 surface deliberately deletes or omits (tools[].execution, + * listChanged/subscribe capability flags on server/discover). Meaningful + * only for `entryModern`. + * - `modern-error-surface` — asserts the 2025-era client-facing error surface + * (ProtocolError with the wire code) for dispatch-window errors; on the + * modern per-request path those errors ride mapped HTTP statuses and the + * client currently surfaces them as SdkHttpError (see the coverage report's + * GAPS FOUND). Meaningful only for `entryModern`. + * - `drives-transport-directly` — the body builds and drives its own transport + * or hosting instead of the wired pair, so an entry cell would duplicate an + * existing cell without exercising the entry. + */ +export const ENTRY_EXCLUSION_REASONS = [ + 'requires-session', + 'method-not-in-modern-registry', + 'asserts-legacy-handshake', + 'legacy-only-vocabulary', + 'modern-error-surface', + 'drives-transport-directly' +] as const; +export type EntryExclusionReason = (typeof ENTRY_EXCLUSION_REASONS)[number]; + +export interface EntryExclusion { + /** The entry arm excluded; omit to exclude both arms. */ + arm?: EntryTransport; + reason: EntryExclusionReason; + /** Optional elaboration beyond the machine-readable reason. */ + note?: string; +} + export interface Requirement { source: string; behavior: string; @@ -39,6 +115,15 @@ export interface Requirement { /** Free-form rationale for how the entry is set up (e.g. why certain transports are excluded). */ note?: string; + /** + * Exclusions from the createMcpHandler entry arms (`entryStateless` / + * `entryModern`), each with a machine-readable reason. Only meaningful when + * the requirement's transports would otherwise include the targeted arm + * (the default `ALL_TRANSPORTS` does); an explicit `transports` list that + * already omits the entry arms needs no annotation here. + */ + entryExclusions?: readonly EntryExclusion[]; + /** First / last spec versions a requirement applies to; changed behaviors are sibling entries linked via `supersedes`/`supersededBy`. */ addedInSpecVersion?: SpecVersion; removedInSpecVersion?: SpecVersion; From 3305949f2c98164d39a6d0109abd6a53f9241e5f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 16:44:03 +0000 Subject: [PATCH 7/8] test(e2e): run the existing scenario corpus through the entry arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entry arms join the default transport list, so every requirement without an explicit transports restriction now also registers an entryStateless cell on the 2025-11-25 axis (the legacy 'stateless' slot) and an entryModern cell on the 2026-07-28 axis (the modern per-request path). Requirements that cannot run on an entry arm carry a machine-readable entryExclusions annotation — the reasons (deleted 2026 methods, initialize-handshake assertions, deliberately omitted 2025 vocabulary, the open modern error surface, stray-message delivery, self-hosted bodies) double as the re-admission checklist — and a coverage gate rejects annotations that would never have an effect. --- test/e2e/CLAUDE.md | 20 ++++++++++ test/e2e/coverage.test.ts | 29 ++++++++++++++ test/e2e/requirements.ts | 80 +++++++++++++++++++++++++++++++++++++++ test/e2e/types.ts | 15 ++++++-- 4 files changed, 140 insertions(+), 4 deletions(-) diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index 7ecb2e06e4..c72d8f2a6e 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -58,6 +58,26 @@ note: 'stateless hosting has no server→client back-channel' `addedInSpecVersion` / `removedInSpecVersion` bound the spec versions a requirement applies to. A behavior changed by a spec release gets a sibling entry: the new entry lists every retired id it replaces in `supersedes` (an array, requires `addedInSpecVersion`), and each retired entry points back via `supersededBy` (requires `removedInSpecVersion`). A coverage gate enforces that the links resolve and are exactly symmetric. +## The createMcpHandler entry arms (entryStateless / entryModern) + +Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: + +- `entryStateless` — the entry with the `legacy: 'stateless'` slot; the scenario's plain client is served per request through the slot. Cells run on the 2025-11-25 axis only. +- `entryModern` — the entry modern-only strict (no legacy slot); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the client + emits it itself). Cells run on the 2026-07-28 axis only. + +Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: + +```ts +entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' /* optional note */ }]; +``` + +Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the +per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. + +Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a bring-your-own `legacy` slot value), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable +response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. + ## Running From the repo root (the suite is the `@modelcontextprotocol/test-e2e` workspace package): diff --git a/test/e2e/coverage.test.ts b/test/e2e/coverage.test.ts index ed580b9a74..4397ae5420 100644 --- a/test/e2e/coverage.test.ts +++ b/test/e2e/coverage.test.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url'; import { expect, test } from 'vitest'; import { REQUIREMENTS } from './requirements.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from './types.js'; const E2E_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -88,6 +89,34 @@ test('every transport-restricted requirement explains why in note', () => { expect(missing).toEqual([]); }); +test('every entryExclusions annotation targets an entry arm the requirement would otherwise run on', () => { + const bad: string[] = []; + for (const [id, r] of Object.entries(REQUIREMENTS)) { + for (const exclusion of r.entryExclusions ?? []) { + const arms = exclusion.arm === undefined ? ENTRY_TRANSPORTS : [exclusion.arm]; + for (const arm of arms) { + const transports = r.transports ?? ALL_TRANSPORTS; + if (!transports.includes(arm)) { + bad.push(`${id}: entryExclusions targets '${arm}', which the requirement's transports never include`); + continue; + } + const versions = ALL_SPEC_VERSIONS.filter( + v => + (r.addedInSpecVersion === undefined || v >= r.addedInSpecVersion) && + (r.removedInSpecVersion === undefined || v < r.removedInSpecVersion) && + (TRANSPORT_SPEC_VERSIONS[arm]?.includes(v) ?? true) + ); + if (versions.length === 0) { + bad.push( + `${id}: entryExclusions targets '${arm}', which registers no cells within the requirement's spec-version bounds` + ); + } + } + } + } + expect(bad).toEqual([]); +}); + test('supersedes/supersededBy links are symmetric and resolve', () => { const bad: string[] = []; for (const [id, req] of Object.entries(REQUIREMENTS)) { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index fc818398d6..11c37acfbb 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -26,6 +26,7 @@ export const REQUIREMENTS: Record = { behavior: 'The client rejects calls to methods (e.g. resources/list) for capabilities the server did not advertise.' }, 'lifecycle:initialize:basic': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'Connecting sends initialize with the protocol version, client capabilities, and client info; the server responds with its own and the connection is established.' @@ -35,24 +36,29 @@ export const REQUIREMENTS: Record = { behavior: 'A server may include an instructions string in the initialize result; the client exposes it.' }, 'lifecycle:initialized-notification': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'After successful initialization, the client sends exactly one initialized notification, before any non-ping request.' }, 'lifecycle:ping': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#behavior-requirements', behavior: 'ping in either direction returns an empty result.' }, 'lifecycle:version:downgrade': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When the server returns an older supported protocol version, the client downgrades to it and the connection succeeds at that version.' }, 'lifecycle:version:match': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When the server supports the requested protocol version it echoes that version in the initialize result, and the connection proceeds at that version.' }, 'lifecycle:version:reject-unsupported': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'When server returns a protocolVersion the client does not support, connect rejects and the transport is closed.', knownFailures: [ @@ -87,11 +93,13 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'lifecycle:version:server-fallback-latest': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation', behavior: 'An initialize request carrying a protocol version the server does not support is answered with another version the server supports — the latest one — rather than an error.' }, 'lifecycle:pre-initialization-ordering': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization', behavior: 'Before initialization completes, the client sends no requests other than pings, and the server sends no requests other than pings and logging.' @@ -150,6 +158,13 @@ export const REQUIREMENTS: Record = { ] }, 'protocol:cancel:unknown-id-ignored': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'The body proves liveness after the ignored cancellation with ping, which the 2026-07-28 registry deletes; the ignored-cancellation behavior itself is still modern.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#error-handling', behavior: 'The receiver silently ignores a cancellation notification referencing an unknown or already-completed request id; no error response is sent and no exception is raised.' @@ -180,6 +195,7 @@ export const REQUIREMENTS: Record = { behavior: 'A request with malformed params is answered with JSON-RPC error -32602 Invalid params.' }, 'protocol:error:method-not-found': { + entryExclusions: [{ arm: 'entryModern', reason: 'modern-error-surface' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#responses', behavior: 'A request whose method has no registered handler is answered with a METHOD_NOT_FOUND error.' }, @@ -237,6 +253,13 @@ export const REQUIREMENTS: Record = { behavior: 'When a request times out, the sender issues notifications/cancelled for that request before failing the local call.' }, 'mcpserver:onerror:reach-through': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'requires-session', + note: 'The body delivers stray responses to a connected instance; on the modern path the entry classifier rejects posted responses before any per-request instance exists.' + } + ], source: 'sdk', behavior: 'Setting mcpServer.server.onerror (or server.onerror on raw Server) receives both transport-level errors and protocol/handler errors (uncaught notification handler, failed-to-send-response, unknown-message-id). The reach-through via McpServer.server is the supported access path until McpServer exposes onerror directly.' @@ -254,6 +277,13 @@ export const REQUIREMENTS: Record = { "A user-defined request schema registered via server.setRequestHandler(CustomSchema, h) is dispatched when client.request({method:'x/custom', params}, CustomResultSchema) is called; the handler's return value is parsed by the result schema and resolved to the caller. Capability checks do not reject non-spec method names." }, 'protocol:custom-method:roundtrip': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'modern-error-surface', + note: 'The custom-method round trip itself serves fine; the body also asserts the -32601 surface for a never-registered method, which differs on the modern path.' + } + ], source: 'sdk', behavior: "server.setRequestHandler with a schema whose method literal is NOT in the MCP spec registers a handler; client.request({method:''}, ResultSchema) returns the handler's result, not -32601 MethodNotFound. Capability assertions on both sides pass through unknown methods." @@ -281,6 +311,7 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'protocol:request-handler:override-builtin': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'sdk', behavior: 'server.setRequestHandler() for a spec method that has a built-in handler (initialize, ping, logging/setLevel) replaces that handler; the user-supplied result is what the client receives. No throw on re-registration.' @@ -358,6 +389,13 @@ export const REQUIREMENTS: Record = { behavior: 'tools/call for a name the server does not recognise returns a JSON-RPC error.' }, 'tools:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#capabilities', behavior: 'A server that exposes tools declares the tools capability (optionally with listChanged) in its InitializeResult.' }, @@ -389,6 +427,13 @@ export const REQUIREMENTS: Record = { behavior: 'tools/list returns the registered tools with name, description, and inputSchema.' }, 'tools:list:metadata': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'The 2026-07-28 wire deletes tools[].execution (taskSupport), which this body asserts round-trips.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool', behavior: 'tools/list includes title, annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), _meta, icons, and execution.taskSupport when set.' @@ -498,6 +543,13 @@ export const REQUIREMENTS: Record = { 'Resources, resource templates, and resource contents may carry annotations {audience, priority, lastModified}; these round-trip from server registration to the client list/read result.' }, 'resources:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', behavior: 'A server with resource handlers advertises the resources capability, including the subscribe sub-flag when a subscribe handler is registered.' @@ -541,6 +593,7 @@ export const REQUIREMENTS: Record = { behavior: 'resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).' }, 'resources:subscribe:capability-required': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities', behavior: 'resources/subscribe to a server that did not advertise the subscribe capability is rejected with an error.' }, @@ -605,6 +658,13 @@ export const REQUIREMENTS: Record = { // Prompts 'prompts:capability:declared': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'legacy-only-vocabulary', + note: 'server/discover deliberately omits the listChanged capability flag this body asserts.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#capabilities', behavior: 'A server with a list_prompts handler advertises the prompts capability in its initialize result.' }, @@ -716,6 +776,7 @@ export const REQUIREMENTS: Record = { behavior: 'The completion result carries values (at most 100), an optional total, and an optional hasMore flag.' }, 'completion:complete:not-supported': { + entryExclusions: [{ arm: 'entryModern', reason: 'modern-error-surface' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#capabilities', behavior: 'A server with no completion handler does not advertise the completions capability and rejects completion/complete with METHOD_NOT_FOUND.' @@ -733,6 +794,13 @@ export const REQUIREMENTS: Record = { behavior: 'A server that emits log message notifications declares the logging capability in its initialize result.' }, 'logging:message:fields': { + entryExclusions: [ + { + arm: 'entryModern', + reason: 'method-not-in-modern-registry', + note: 'The body scaffolds the exchange with logging/setLevel, which the 2026-07-28 registry deletes; notifications/message itself is still modern vocabulary.' + } + ], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#log-message-notifications', behavior: "A log message sent by a server handler is delivered to the client's logging callback with its severity level, logger name, and data." @@ -750,6 +818,7 @@ export const REQUIREMENTS: Record = { note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, 'logging:set-level:invalid-level': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#error-handling', behavior: 'logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).', knownFailures: [ @@ -1134,11 +1203,13 @@ export const REQUIREMENTS: Record = { behavior: "_meta returned in a handler's result is delivered intact to the requesting client." }, 'protocol:request-id:unique': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#requests', behavior: 'Every request sent on a session carries a unique, non-null string or integer id; ids are never reused within the session.' }, 'protocol:notifications:no-response': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#notifications', behavior: 'Notifications are never answered: every message the server delivers is either the response to a request the client sent or a notification carrying no id.' @@ -2300,6 +2371,7 @@ export const REQUIREMENTS: Record = { note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' }, 'typescript:method-string-handlers:result-type-inference': { + entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' }], source: 'sdk', behavior: 'client.request() called with a spec method string and no result schema resolves with the result already parsed and validated for that method (ResultTypeMap inference), e.g. tools/list yields a usable tools array without passing a schema.' @@ -2403,11 +2475,13 @@ export const REQUIREMENTS: Record = { note: "This exercises the HTTP client transport's reconnection path; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs." }, 'lifecycle:version:custom-supported-versions': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'sdk', behavior: 'supportedProtocolVersions passed in Client/Server options overrides the negotiation list: a client requesting a version the server supports gets that version back, and both sides report the negotiated version after connect.' }, 'lifecycle:version:no-overlap-rejects': { + entryExclusions: [{ arm: 'entryModern', reason: 'asserts-legacy-handshake' }], source: 'sdk', behavior: "When the server's negotiated protocol version is not in the client's supportedProtocolVersions list, client.connect() rejects and the connection is not established." @@ -2462,6 +2536,12 @@ export const REQUIREMENTS: Record = { note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'transport:standalone:raw-relay': { + entryExclusions: [ + { + reason: 'drives-transport-directly', + note: 'The body builds and hosts its own raw transports per matrix arm; an entry cell would re-run the streamable HTTP relay without exercising the entry.' + } + ], source: 'sdk', behavior: 'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.', diff --git a/test/e2e/types.ts b/test/e2e/types.ts index c82673e026..8887f60322 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -2,7 +2,16 @@ * Shared types for the e2e suite. */ -export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamableHttpStateless', 'sse'] as const; +export const ALL_TRANSPORTS = [ + 'inMemory', + 'stdio', + 'streamableHttp', + 'streamableHttpStateless', + 'sse', + 'entryStateless', + 'entryModern' +] as const; +export type Transport = (typeof ALL_TRANSPORTS)[number]; /** * The createMcpHandler entry arms: the dual-era HTTP entry hosted in process @@ -12,11 +21,9 @@ export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamabl * the entry's modern (per-request envelope) path. Each arm is era-fixed, so it * registers cells on exactly one spec-version axis (see TRANSPORT_SPEC_VERSIONS). */ -export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const; +export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const satisfies readonly Transport[]; export type EntryTransport = (typeof ENTRY_TRANSPORTS)[number]; -export type Transport = (typeof ALL_TRANSPORTS)[number] | EntryTransport; - /** * Every spec version the manifest may reference — used for typing * `addedInSpecVersion` / `removedInSpecVersion` bounds and knownFailure From 5912144f62dac469f93ce68e10bed907f1a35a50 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 16 Jun 2026 16:44:20 +0000 Subject: [PATCH 8/8] test(e2e): rewire the createMcpHandler coverage cells onto the entry arms The hosting-entry scenarios no longer hand-host the handler's node face on their own listeners: every cell is wired through the entryStateless or entryModern arm, the requirement rows name the arm(s) they actually run on, and the per-scenario explicit-envelope and recording-fetch helpers are replaced by the arm's envelope stop-gap and the recorded httpLog. The bring-your-own sessionful slot and the responseMode variants use wire()'s entry hosting overrides; assertions are unchanged in substance. --- test/e2e/requirements.ts | 36 +-- .../scenarios/hosting-entry-session.test.ts | 35 ++- .../scenarios/hosting-entry-stamping.test.ts | 201 +++++++---------- .../scenarios/hosting-entry-streaming.test.ts | 208 +++++++----------- test/e2e/scenarios/hosting-entry.test.ts | 207 +++++++---------- 5 files changed, 266 insertions(+), 421 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 11c37acfbb..567c55e023 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2273,71 +2273,71 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'createMcpHandler serves one ctx-taking factory to both protocol eras on one endpoint: with the legacy "stateless" slot configured, a plain client is served per request via initialize, tools/list and tools/call on the 2025 era, and an auto-negotiating client reaches 2026-07-28 via server/discover (never initialize) and gets tools/call served with the per-request _meta envelope.', - transports: ['streamableHttp'], - note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which era the cell drives.' + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms (the same one-factory, legacy-stateless-slot handler shape on both): the entryStateless cell drives the 2025 leg through the slot and the entryModern cell drives the modern path, with the never-initialize/server-discover clauses asserted on the arm-recorded HTTP exchanges.' }, 'typescript:hosting:entry:pin-negotiation': { source: 'sdk', behavior: 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', - transports: ['streamableHttp'], + transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges.' }, 'typescript:hosting:entry:strict-rejects-legacy': { source: 'sdk', behavior: 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', - transports: ['streamableHttp'], + transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' }, 'typescript:hosting:entry:notification-202': { source: 'sdk', behavior: 'A POST carrying only a notification is answered 202 Accepted with an empty body by a createMcpHandler endpoint on both legs: an envelope-less notification through the legacy stateless slot and an envelope-carrying notification on the modern path.', - transports: ['streamableHttp'], - note: 'The entry is an HTTP-only surface; each cell hosts the handler node face on a real node:http listener (the matrix transport arg is ignored, fixed to a single streamableHttp-labelled cell) and the spec-version axis selects which leg the notification rides. The cells pin the HTTP contract only (status code and empty body); delivery of the notification to the per-request server instance is pinned at unit level.' + transports: ['entryStateless', 'entryModern'], + note: 'Runs on the createMcpHandler entry arms; each cell POSTs the raw notification through wired.fetch so the HTTP contract (status code and empty body) is observed directly, and the arm selects which leg the notification rides. Delivery of the notification to the per-request server instance is pinned at unit level.' }, 'typescript:hosting:entry:modern-cacheable-stamping': { source: 'sdk', behavior: 'Typed tools/list, resources/read and resources/list round trips negotiated on 2026-07-28 over a createMcpHandler endpoint succeed, and the wire results carry resultType "complete" plus the required ttlMs/cacheScope fields, with the configured-hint precedence observable on the wire: the per-resource cacheHint wins over the per-operation cacheHints entry (resources/read), a per-operation hint wins over the defaults (tools/list), and a result with no configured author is filled with the ttlMs 0 / cacheScope private defaults (resources/list).', - transports: ['streamableHttp'], + transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis. The top precedence rung — a handler-returned ttlMs/cacheScope value winning over every configured hint — is pinned at unit level and not exercised here.' + note: 'Runs on the entryModern arm; the typed round trips go through the wired negotiating client and the wire-level stamping is asserted on the arm-recorded response bytes. The top precedence rung — a handler-returned ttlMs/cacheScope value winning over every configured hint — is pinned at unit level and not exercised here.' }, 'typescript:hosting:entry:legacy-cacheable-suppression': { source: 'sdk', behavior: 'A factory with every cache-hint author configured (per-operation cacheHints and a per-resource cacheHint), served to a plain 2025 client through the legacy stateless slot of a createMcpHandler endpoint, answers tools/list and resources/read with no resultType, ttlMs, cacheScope or cacheHint vocabulary anywhere in the response bytes.', - transports: ['streamableHttp'], + transports: ['entryStateless'], removedInSpecVersion: '2026-07-28', - note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell.' + note: 'The suppression invariant is a statement about 2025-era serving, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm; the response bytes are asserted on the arm-recorded HTTP exchanges.' }, 'typescript:hosting:entry:byo-sessionful-legacy': { source: 'sdk', behavior: 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', - transports: ['streamableHttp'], + transports: ['entryStateless'], removedInSpecVersion: '2026-07-28', - note: 'The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis; the cell hosts the handler node face on a real node:http listener, so the matrix transport arg is ignored and fixed to a single streamableHttp-labelled cell. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted.' + note: "The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm with the slot overridden via wire()'s entry.legacy option. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted." }, 'typescript:hosting:entry:modern-lazy-sse-upgrade': { source: 'sdk', behavior: 'On the default response mode, a modern (2026-07-28) request exchange over a createMcpHandler endpoint is answered as a single JSON body when the handler emits nothing before its result, and upgrades to an SSE stream when the handler emits related notifications mid-call: the response content-type becomes text/event-stream and the frames carry the notifications in emission order with the terminal result as the last frame.', - transports: ['streamableHttp'], + transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts the handler node face on a real node:http listener and drives it with the negotiating client over a recording fetch, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + note: 'Runs on the entryModern arm; the typed calls go through the wired negotiating client and the response shape (status, content-type, SSE frame order) is asserted on the arm-recorded HTTP exchanges.' }, 'typescript:hosting:entry:modern-response-mode': { source: 'sdk', behavior: 'The createMcpHandler responseMode option shapes modern (2026-07-28) request exchanges end to end: "sse" answers over an SSE stream even when the handler emits nothing before its result, and "json" answers with a single JSON body whose only payload is the terminal result — mid-call notifications are dropped, not buffered.', - transports: ['streamableHttp'], + transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'The entry is an HTTP-only surface; the cell hosts one endpoint per responseMode value on a real node:http listener and drives both with the negotiating client over a recording fetch, so the matrix transport arg is ignored and the requirement is fixed to a single streamableHttp-labelled cell on the 2026-07-28 axis.' + note: "Runs on the entryModern arm; the body wires one harness-hosted endpoint per responseMode value via wire()'s entry.responseMode option and asserts the response shape on the arm-recorded HTTP exchanges." }, 'typescript:transport:stdio:dual-era-serving': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index c1d19458c4..40d042104b 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -1,39 +1,37 @@ /** * Sessionful 2025-era serving through the dual-era HTTP entry's - * bring-your-own legacy slot. + * bring-your-own legacy slot, exercised on the wire() entryStateless arm with + * the slot overridden via `wire()`'s `entry.legacy` option. * * The legacy slot value is a real sessionful wiring — one * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by * the Mcp-Session-Id the transport itself issues (the documented sessionful * hosting pattern) — and a plain 2025 SDK client drives the full session - * lifecycle through `createMcpHandler` over a real socket: initialize issues a + * lifecycle through the harness-hosted `createMcpHandler`: initialize issues a * session id, a follow-up POST is served on that session, the body-less GET * opens the standalone SSE stream, and DELETE tears the session down. Every * exchange the slot serves is recorded as it leaves the wiring (method, status, * content-type), so the entry's routing of GET/DELETE (no envelope, no body → * legacy slot) to the bring-your-own handler is pinned directly; byte-level * forwarding fidelity is not asserted here. - * - * Like hosting-entry.test.ts these bodies host the handler's node face on a - * real node:http listener and do not use `wire()`. */ import { randomUUID } from 'node:crypto'; -import { createServer } from 'node:http'; -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { Client } from '@modelcontextprotocol/client'; import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; +import { wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; const LEGACY = '2025-11-25'; /** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ -function modernFactory(_ctx: McpRequestContext): McpServer { +function modernFactory(_ctx?: McpRequestContext): McpServer { const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ content: [{ type: 'text', text: `hello ${name} (modern)` }] @@ -41,7 +39,7 @@ function modernFactory(_ctx: McpRequestContext): McpServer { return server; } -verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArgs) => { +verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: TestArgs) => { // The documented sessionful wiring, passed as the bring-your-own legacy // slot value: a fresh transport per initialize, kept in a map keyed by the // Mcp-Session-Id it issues; later requests are routed by that header. @@ -88,18 +86,17 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArg return response; }; - const handler = createMcpHandler(modernFactory, { legacy: sessionfulLegacy }); - const httpServer = createServer((req, res) => void handler.node(req, res)); - const baseUrl = await listenOnRandomPort(httpServer); - - const clientTransport = new StreamableHTTPClientTransport(baseUrl); const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); try { + // The harness hosts the entry; the bring-your-own wiring replaces the + // arm's default 'stateless' slot value. + await using wired = await wire(transport, modernFactory, client, { entry: { legacy: sessionfulLegacy } }); + // initialize → the bring-your-own transport issues an Mcp-Session-Id. // (The stateless slot never issues one, so a defined session id alone // proves the request reached the bring-your-own wiring.) - await client.connect(clientTransport); expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const clientTransport = client.transport as StreamableHTTPClientTransport; const sessionId = clientTransport.sessionId; expect(sessionId).toBeDefined(); expect(sessions.has(sessionId!)).toBe(true); @@ -135,7 +132,7 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArg // The dead session is gone: a POST carrying its id is answered 404 by // the bring-your-own wiring, not silently re-served by anything else. - const stale = await fetch(baseUrl, { + const stale = await wired.fetch!(wired.url!, { method: 'POST', headers: { 'content-type': 'application/json', @@ -153,7 +150,5 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async (_args: TestArg } finally { await client.close().catch(() => {}); for (const server of sessionServers) await server.close().catch(() => {}); - await handler.close(); - await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); } }); diff --git a/test/e2e/scenarios/hosting-entry-stamping.test.ts b/test/e2e/scenarios/hosting-entry-stamping.test.ts index 758811603a..6ef259ba16 100644 --- a/test/e2e/scenarios/hosting-entry-stamping.test.ts +++ b/test/e2e/scenarios/hosting-entry-stamping.test.ts @@ -2,9 +2,9 @@ * Result stamping and cache-field fill, end to end over the dual-era HTTP * entry (`createMcpHandler`), with the era boundary asserted on the wire: * - * - 2026-07-28 cells: typed tools/list, resources/read and resources/list - * round trips through the negotiating client succeed, and the captured wire - * results carry `resultType: 'complete'` plus the required + * - the entryModern cell (2026-07-28 axis): typed tools/list, resources/read + * and resources/list round trips through the negotiating client succeed, and + * the recorded wire results carry `resultType: 'complete'` plus the required * `ttlMs`/`cacheScope` fields, with three rungs of the documented precedence * observable on the wire: the per-resource hint wins over the per-operation * hint (resources/read), a per-operation hint wins over the defaults @@ -12,24 +12,22 @@ * `{ ttlMs: 0, cacheScope: 'private' }` defaults (resources/list). The top * rung — a handler-returned value winning over every configured hint — is * pinned at unit level (encodeContract), not here. - * - 2025-11-25 cells: the same fully cache-hint-configured factory served to a - * plain client through the legacy stateless slot answers the same calls with - * none of that vocabulary anywhere in the response bytes. + * - the entryStateless cell (2025-11-25 axis): the same fully + * cache-hint-configured factory served to a plain client through the legacy + * stateless slot answers the same calls with none of that vocabulary + * anywhere in the response bytes. * - * Like hosting-entry.test.ts these bodies host the handler's node face on a - * real node:http listener and do not use `wire()`. + * Both cells run through the wire() entry arms; the raw response bytes come + * from the arm-recorded `wired.httpLog`. */ -import type { Server as HttpServer } from 'node:http'; -import { createServer } from 'node:http'; - -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { Client } from '@modelcontextprotocol/client'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; import { z } from 'zod/v4'; +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -39,14 +37,6 @@ const MODERN = '2026-07-28'; /** The cache-field vocabulary that must never appear on a 2025-era response. */ const CACHE_VOCABULARY = ['"resultType"', '"ttlMs"', '"cacheScope"', '"cacheHint"'] as const; -function modernEnvelope() { - return { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'e2e-stamping-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; -} - /** * One ctx-taking factory with every cache-hint author configured: * - a per-operation hint for tools/list (the funnel-built result with no other author), @@ -54,7 +44,7 @@ function modernEnvelope() { * registered resource, so the documented precedence (per-resource wins) is * observable on the wire. */ -function cacheConfiguredFactory(_ctx: McpRequestContext): McpServer { +function cacheConfiguredFactory(_ctx?: McpRequestContext): McpServer { const server = new McpServer( { name: 'e2e-entry-cache', version: '1.0.0' }, { @@ -74,32 +64,9 @@ function cacheConfiguredFactory(_ctx: McpRequestContext): McpServer { return server; } -interface Endpoint extends AsyncDisposable { - baseUrl: URL; - handler: McpHttpHandler; -} - -async function startEndpoint(): Promise { - const handler = createMcpHandler(cacheConfiguredFactory, { legacy: 'stateless' }); - const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); - const baseUrl = await listenOnRandomPort(httpServer); - return { - baseUrl, - handler, - [Symbol.asyncDispose]: async () => { - await handler.close(); - await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); - } - }; -} - -/** Records every HTTP response body the client receives so wire bytes can be asserted alongside the typed results. */ -function recordingFetch(responseBodies: string[]): typeof fetch { - return async (input, init) => { - const response = await fetch(input, init); - responseBodies.push(await response.clone().text()); - return response; - }; +/** The raw response bodies of every recorded HTTP exchange, in order. */ +function responseBodies(wired: Wired): Promise { + return Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text())); } /** Parses a captured response body (plain JSON or SSE-framed) into its JSON-RPC messages. */ @@ -120,8 +87,8 @@ function jsonRpcMessagesFrom(text: string): Array> { } /** Finds the wire result of the response message whose result carries the given key. */ -function wireResultWith(responseBodies: string[], key: string): Record | undefined { - for (const body of responseBodies) { +function wireResultWith(bodies: string[], key: string): Record | undefined { + for (const body of bodies) { for (const message of jsonRpcMessagesFrom(body)) { const result = message.result as Record | undefined; if (result && key in result) return result; @@ -130,82 +97,64 @@ function wireResultWith(responseBodies: string[], key: string): Record { - await using endpoint = await startEndpoint(); - - const responseBodies: string[] = []; +verifies('typescript:hosting:entry:modern-cacheable-stamping', async ({ transport }: TestArgs) => { const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch(responseBodies) })); - - try { - expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); - - // Typed round trips (the 2026 wire result schemas require the cache - // fields, so a successful decode is itself part of the assertion). - const list = (await client.request({ method: 'tools/list', params: { _meta: modernEnvelope() } })) as { - tools: Array<{ name: string }>; - }; - expect(list.tools.map(tool => tool.name)).toEqual(['greet']); - - const read = (await client.request({ - method: 'resources/read', - params: { uri: 'memo://note', _meta: modernEnvelope() } - })) as { contents: Array<{ text?: string }> }; - expect(read.contents[0]?.text).toBe('cached note'); - - const resourceList = (await client.request({ method: 'resources/list', params: { _meta: modernEnvelope() } })) as { - resources: Array<{ uri: string }>; - }; - expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']); - - // Wire-level: resultType is stamped and the cache fields carry the - // configured hints. tools/list has only the per-operation author (its - // hint wins over the defaults); resources/read shows the per-resource - // hint winning over the per-operation hint; resources/list has no - // configured author at all and is filled with the documented defaults. - const listResult = wireResultWith(responseBodies, 'tools'); - expect(listResult).toBeDefined(); - expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); - - const readResult = wireResultWith(responseBodies, 'contents'); - expect(readResult).toBeDefined(); - expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); - - const resourceListResult = wireResultWith(responseBodies, 'resources'); - expect(resourceListResult).toBeDefined(); - expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); - } finally { - await client.close(); - } + await using wired = await wire(transport, cacheConfiguredFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Typed round trips (the 2026 wire result schemas require the cache + // fields, so a successful decode is itself part of the assertion). + const list = await client.listTools(); + expect(list.tools.map(tool => tool.name)).toEqual(['greet']); + + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + const resourceList = await client.listResources(); + expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']); + + // Wire-level: resultType is stamped and the cache fields carry the + // configured hints. tools/list has only the per-operation author (its + // hint wins over the defaults); resources/read shows the per-resource + // hint winning over the per-operation hint; resources/list has no + // configured author at all and is filled with the documented defaults. + const bodies = await responseBodies(wired); + const listResult = wireResultWith(bodies, 'tools'); + expect(listResult).toBeDefined(); + expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' }); + + const readResult = wireResultWith(bodies, 'contents'); + expect(readResult).toBeDefined(); + expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' }); + + const resourceListResult = wireResultWith(bodies, 'resources'); + expect(resourceListResult).toBeDefined(); + expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' }); }); -verifies('typescript:hosting:entry:legacy-cacheable-suppression', async (_args: TestArgs) => { - await using endpoint = await startEndpoint(); - - const responseBodies: string[] = []; +verifies('typescript:hosting:entry:legacy-cacheable-suppression', async ({ transport }: TestArgs) => { const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch(responseBodies) })); - - try { - expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); - - // The same calls, typed, on the 2025 leg (served through the legacy stateless slot). - const tools = await client.listTools(); - expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); - const read = await client.readResource({ uri: 'memo://note' }); - const firstContent = read.contents[0]; - expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); - - // None of the 2026 cache vocabulary appears anywhere in the bytes of - // any response of this conversation, even though every cache-hint - // author is configured on the factory. - const conversation = responseBodies.join('\n'); - expect(conversation).toContain('"tools"'); - expect(conversation).toContain('"contents"'); - for (const term of CACHE_VOCABULARY) { - expect(conversation).not.toContain(term); - } - } finally { - await client.close(); + await using wired = await wire(transport, cacheConfiguredFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + + // The same calls, typed, on the 2025 leg (served through the legacy stateless slot). + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const read = await client.readResource({ uri: 'memo://note' }); + const firstContent = read.contents[0]; + expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note'); + + // None of the 2026 cache vocabulary appears anywhere in the bytes of + // any response of this conversation, even though every cache-hint + // author is configured on the factory. + const bodies = await responseBodies(wired); + const conversation = bodies.join('\n'); + expect(conversation).toContain('"tools"'); + expect(conversation).toContain('"contents"'); + for (const term of CACHE_VOCABULARY) { + expect(conversation).not.toContain(term); } }); diff --git a/test/e2e/scenarios/hosting-entry-streaming.test.ts b/test/e2e/scenarios/hosting-entry-streaming.test.ts index fda8a3a98b..6b6ec0c0cd 100644 --- a/test/e2e/scenarios/hosting-entry-streaming.test.ts +++ b/test/e2e/scenarios/hosting-entry-streaming.test.ts @@ -1,6 +1,6 @@ /** * Modern-era (2026-07-28) response streaming through the dual-era HTTP entry, - * observed on a real socket: + * exercised on the wire() entryModern arm: * * - default response mode: a handler that emits nothing before its result is * answered as a single JSON body; a handler that emits related notifications @@ -11,42 +11,29 @@ * - `responseMode: 'json'` never streams and drops mid-call notifications — * only the terminal result is delivered. * - * Every body hosts the handler's node face on a real node:http listener and - * drives it with the auto-negotiating client over a recording fetch, so the - * typed result and the raw wire bytes are asserted side by side. Like - * hosting-entry.test.ts these bodies do not use `wire()`. + * Every body drives the harness-hosted entry with the auto-negotiating client; + * the typed result and the raw wire bytes (status, content-type, SSE frames) + * are asserted side by side via the arm-recorded `wired.httpLog`. */ -import type { Server as HttpServer } from 'node:http'; -import { createServer } from 'node:http'; - -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { Client } from '@modelcontextprotocol/client'; +import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; import { z } from 'zod/v4'; +import type { Wired } from '../helpers/index.js'; +import { wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; const MODERN = '2026-07-28'; -/** The per-request `_meta` envelope every 2026-era request carries (attached explicitly until automatic emission lands client-side). */ -function modernEnvelope() { - return { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name: 'e2e-streaming-client', version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; -} - /** * One factory with a quiet tool (no streamed output) and a chatty tool (two * logging notifications emitted before its result), so the lazy upgrade and * both forced response modes are observable per call. */ -function streamingFactory(_ctx: McpRequestContext): McpServer { +function streamingFactory(_ctx?: McpRequestContext): McpServer { const server = new McpServer({ name: 'e2e-entry-streaming', version: '1.0.0' }, { capabilities: { tools: {}, logging: {} } }); server.registerTool('quiet', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'quiet result' }] @@ -59,43 +46,21 @@ function streamingFactory(_ctx: McpRequestContext): McpServer { return server; } -interface Endpoint extends AsyncDisposable { - baseUrl: URL; - handler: McpHttpHandler; -} - -/** Hosts the handler's node face on a real node:http listener bound to an ephemeral port. */ -async function startEndpoint(options?: CreateMcpHandlerOptions): Promise { - const handler = createMcpHandler(streamingFactory, options); - const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); - const baseUrl = await listenOnRandomPort(httpServer); - return { - baseUrl, - handler, - [Symbol.asyncDispose]: async () => { - await handler.close(); - await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); - } - }; -} - interface RecordedResponse { status: number; contentType: string; body: string; } -/** Records every HTTP response (status, content-type, raw body bytes) the client receives. */ -function recordingFetch(responses: RecordedResponse[]): typeof fetch { - return async (input, init) => { - const response = await fetch(input, init); - responses.push({ - status: response.status, - contentType: response.headers.get('content-type') ?? '', - body: await response.clone().text() - }); - return response; - }; +/** Every recorded HTTP response (status, content-type, raw body bytes), in exchange order. */ +function recordedResponses(wired: Wired): Promise { + return Promise.all( + (wired.httpLog ?? []).map(async exchange => ({ + status: exchange.status, + contentType: exchange.contentType, + body: await exchange.response.text() + })) + ); } /** The `data:` payloads of an SSE-framed body, parsed, in frame order. */ @@ -106,92 +71,83 @@ function sseDataFrames(body: string): Array> { .map(line => JSON.parse(line.slice('data: '.length)) as Record); } -async function connectAutoClient(baseUrl: URL, responses: RecordedResponse[]): Promise { - const client = new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StreamableHTTPClientTransport(baseUrl, { fetch: recordingFetch(responses) })); - expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); - return client; +function newAutoClient(): Client { + return new Client({ name: 'e2e-streaming-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); } function callTool(client: Client, name: 'quiet' | 'chatty'): Promise { - return client.request({ - method: 'tools/call', - params: { name, arguments: {}, _meta: modernEnvelope() } - }) as Promise; + return client.callTool({ name, arguments: {} }) as Promise; } -verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async (_args: TestArgs) => { - await using endpoint = await startEndpoint(); - - const responses: RecordedResponse[] = []; - const client = await connectAutoClient(endpoint.baseUrl, responses); - try { - // Quiet handler: nothing emitted before the result → a single JSON body. - const quiet = await callTool(client, 'quiet'); - expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]); - const quietResponse = responses.find(response => response.body.includes('quiet result')); - expect(quietResponse).toBeDefined(); - expect(quietResponse!.status).toBe(200); - expect(quietResponse!.contentType).toContain('application/json'); - - // Chatty handler: the first related notification upgrades the exchange - // to SSE — notifications framed in order, terminal result last. - const chatty = await callTool(client, 'chatty'); - expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]); - const chattyResponse = responses.find(response => response.body.includes('chatty result')); - expect(chattyResponse).toBeDefined(); - expect(chattyResponse!.status).toBe(200); - expect(chattyResponse!.contentType).toContain('text/event-stream'); - - const frames = sseDataFrames(chattyResponse!.body); - expect(frames).toHaveLength(3); - expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } }); - expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } }); - expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } }); - } finally { - await client.close(); - } +verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async ({ transport }: TestArgs) => { + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + // Quiet handler: nothing emitted before the result → a single JSON body. + const quiet = await callTool(client, 'quiet'); + expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]); + + // Chatty handler: the first related notification upgrades the exchange + // to SSE — notifications framed in order, terminal result last. + const chatty = await callTool(client, 'chatty'); + expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]); + + const responses = await recordedResponses(wired); + const quietResponse = responses.find(response => response.body.includes('quiet result')); + expect(quietResponse).toBeDefined(); + expect(quietResponse!.status).toBe(200); + expect(quietResponse!.contentType).toContain('application/json'); + + const chattyResponse = responses.find(response => response.body.includes('chatty result')); + expect(chattyResponse).toBeDefined(); + expect(chattyResponse!.status).toBe(200); + expect(chattyResponse!.contentType).toContain('text/event-stream'); + + const frames = sseDataFrames(chattyResponse!.body); + expect(frames).toHaveLength(3); + expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } }); + expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } }); + expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } }); }); -verifies('typescript:hosting:entry:modern-response-mode', async (_args: TestArgs) => { - // One endpoint per responseMode value, both backed by the same factory. - await using sseEndpoint = await startEndpoint({ responseMode: 'sse' }); - await using jsonEndpoint = await startEndpoint({ responseMode: 'json' }); +verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: TestArgs) => { + // One harness-hosted endpoint per responseMode value, both backed by the same factory. // responseMode 'sse': even a handler that emits nothing streams its result. { - const responses: RecordedResponse[] = []; - const client = await connectAutoClient(sseEndpoint.baseUrl, responses); - try { - const result = await callTool(client, 'quiet'); - expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]); - const response = responses.find(candidate => candidate.body.includes('quiet result')); - expect(response).toBeDefined(); - expect(response!.status).toBe(200); - expect(response!.contentType).toContain('text/event-stream'); - const frames = sseDataFrames(response!.body); - expect(frames).toHaveLength(1); - expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } }); - } finally { - await client.close(); - } + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'sse' } }); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await callTool(client, 'quiet'); + expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]); + + const responses = await recordedResponses(wired); + const response = responses.find(candidate => candidate.body.includes('quiet result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('text/event-stream'); + const frames = sseDataFrames(response!.body); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } }); } // responseMode 'json': mid-call notifications are dropped — the response // is a plain JSON body whose only payload is the terminal result. { - const responses: RecordedResponse[] = []; - const client = await connectAutoClient(jsonEndpoint.baseUrl, responses); - try { - const result = await callTool(client, 'chatty'); - expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]); - const response = responses.find(candidate => candidate.body.includes('chatty result')); - expect(response).toBeDefined(); - expect(response!.status).toBe(200); - expect(response!.contentType).toContain('application/json'); - expect(response!.body).not.toContain('notifications/message'); - } finally { - await client.close(); - } + const client = newAutoClient(); + await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'json' } }); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const result = await callTool(client, 'chatty'); + expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]); + + const responses = await recordedResponses(wired); + const response = responses.find(candidate => candidate.body.includes('chatty result')); + expect(response).toBeDefined(); + expect(response!.status).toBe(200); + expect(response!.contentType).toContain('application/json'); + expect(response!.body).not.toContain('notifications/message'); } }); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index a477723f01..e5b3cf08d8 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -1,162 +1,102 @@ /** - * Self-contained test bodies for the dual-era HTTP entry (`createMcpHandler`). - * - * Unlike most scenario areas these do not use `wire()`: every body hosts the - * handler's `node` face on a real `node:http` listener (the same wiring as - * `test/integration/test/server/createMcpHandler.test.ts`) and drives it with - * real SDK clients or plain fetch. The requirements therefore restrict the - * matrix transport axis to a single HTTP transport, and the spec-version axis - * selects which era a cell drives where the requirement spans both. + * Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised + * through the wire() entry arms: `entryStateless` hosts the entry's + * `legacy: 'stateless'` slot for plain 2025-era clients (2025-11-25 axis) and + * `entryModern` hosts the modern-only strict endpoint for negotiating clients + * (2026-07-28 axis). Raw wire facts (request bodies, statuses, response bytes) + * are asserted on the arm-recorded `wired.httpLog`; raw HTTP probes go through + * `wired.fetch` so every exchange still rides the harness-hosted entry. */ -import type { Server as HttpServer } from 'node:http'; -import { createServer } from 'node:http'; - import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; -import type { - CallToolResult, - CreateMcpHandlerOptions, - McpHttpHandler, - McpRequestContext, - McpServerFactory -} from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import { PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; import { z } from 'zod/v4'; +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; const LEGACY = '2025-11-25'; const MODERN = '2026-07-28'; -/** The per-request `_meta` envelope every 2026-era request carries (attached explicitly until automatic emission lands client-side). */ -function modernEnvelope(name = 'e2e-entry-client') { - return { - [PROTOCOL_VERSION_META_KEY]: MODERN, - [CLIENT_INFO_META_KEY]: { name, version: '1.0.0' }, - [CLIENT_CAPABILITIES_META_KEY]: {} - }; -} - /** One ctx-taking factory backing every cell: the era only shows up in the tool output so tests can see which leg served the call. */ -function greetFactory(ctx: McpRequestContext): McpServer { +function greetFactory(ctx?: McpRequestContext): McpServer { const server = new McpServer({ name: 'e2e-entry', version: '1.0.0' }, { capabilities: { tools: {} } }); server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ - content: [{ type: 'text', text: `hello ${name} (${ctx.era})` }] + content: [{ type: 'text', text: `hello ${name} (${ctx?.era ?? 'unknown'})` }] })); return server; } -interface Endpoint extends AsyncDisposable { - baseUrl: URL; - handler: McpHttpHandler; -} - -/** Hosts the handler's node face on a real node:http listener bound to an ephemeral port. */ -async function startEndpoint(factory: McpServerFactory, options?: CreateMcpHandlerOptions): Promise { - const handler = createMcpHandler(factory, options); - const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); - const baseUrl = await listenOnRandomPort(httpServer); - return { - baseUrl, - handler, - [Symbol.asyncDispose]: async () => { - await handler.close(); - await new Promise((resolve, reject) => httpServer.close(error => (error ? reject(error) : resolve()))); - } - }; -} - -verifies('typescript:hosting:entry:dual-era-one-factory', async ({ protocolVersion }: TestArgs) => { - await using endpoint = await startEndpoint(greetFactory, { legacy: 'stateless' }); +verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { + // Both cells host the same handler shape — one ctx-taking factory, legacy + // 'stateless' slot configured — and differ only in the client driving it. + const client = + transport === 'entryModern' + ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) + : new Client({ name: 'plain-2025-client', version: '1.0.0' }); + await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); - if (protocolVersion === LEGACY) { + if (transport === 'entryStateless') { // 2025-era leg: a plain client is served per request through the // legacy 'stateless' slot — initialize → tools/list → tools/call. - const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl)); - try { - expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); - const tools = await client.listTools(); - expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); - const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } }); - expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); - } finally { - await client.close(); - } + expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); + const tools = await client.listTools(); + expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); + const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]); return; } // 2026-era leg: the auto-negotiating client reaches 2026-07-28 via // server/discover — never initialize — and tools/call is served with the - // per-request envelope. - const requestBodies: string[] = []; - const recordingFetch: typeof fetch = async (input, init) => { - if (typeof init?.body === 'string') requestBodies.push(init.body); - return fetch(input, init); - }; - const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch })); - try { - expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); - // The "(never initialize)" clause of the requirement, asserted on the - // recorded wire traffic: no request body ever carried an initialize, - // and the negotiation rode server/discover. - expect(requestBodies.some(body => body.includes('"initialize"'))).toBe(false); - expect(requestBodies.some(body => body.includes('server/discover'))).toBe(true); - const result = (await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'new friend' }, _meta: modernEnvelope('auto-client') } - })) as CallToolResult; - expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); - // ...and still no initialize anywhere on the wire after the tool call — - // the whole conversation rode the modern handshake. - expect(requestBodies.some(body => body.includes('"initialize"'))).toBe(false); - } finally { - await client.close(); - } + // per-request envelope (the modern factory leg answers, not the slot). + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + // The "(never initialize)" clause of the requirement, asserted on the + // recorded wire traffic: no request body ever carried an initialize, + // and the negotiation rode server/discover. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies().some(body => body.includes('server/discover'))).toBe(true); + const result = await client.callTool({ name: 'greet', arguments: { name: 'new friend' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); + // ...and still no initialize anywhere on the wire after the tool call — + // the whole conversation rode the modern handshake. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); }); -verifies('typescript:hosting:entry:pin-negotiation', async (_args: TestArgs) => { - // Strict endpoint (no legacy slot): the pinned client never needs one. - await using endpoint = await startEndpoint(greetFactory); - - const bodies: string[] = []; - const recordingFetch: typeof fetch = async (input, init) => { - if (typeof init?.body === 'string') bodies.push(init.body); - return fetch(input, init); - }; - +verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestArgs) => { + // Strict endpoint (no legacy slot — the entryModern arm default): the pinned client never needs one. const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); - await client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl, { fetch: recordingFetch })); - try { - expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); - // No initialize was ever put on the wire; the first request is the discover probe. - expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); - expect(bodies[0]).toContain('server/discover'); - - const result = (await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'pinned' }, _meta: modernEnvelope('pin-client') } - })) as CallToolResult; - expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); - // ...and still no initialize anywhere on the wire after the tool call. - expect(bodies.some(body => body.includes('"initialize"'))).toBe(false); - } finally { - await client.close(); - } + await using wired = await wire(transport, greetFactory, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? ''); + // No initialize was ever put on the wire; the first request is the discover probe. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); + expect(requestBodies()[0]).toContain('server/discover'); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'pinned' } }); + expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]); + // The tool call rode the per-request envelope on the wire... + const callBody = requestBodies().find(body => body.includes('"tools/call"')); + expect(callBody).toBeDefined(); + expect(callBody).toContain(PROTOCOL_VERSION_META_KEY); + // ...and still no initialize anywhere on the wire after the tool call. + expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false); }); -verifies('typescript:hosting:entry:strict-rejects-legacy', async (_args: TestArgs) => { - // legacy omitted → modern-only strict: no silent 2025 serving. - await using endpoint = await startEndpoint(greetFactory); +verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { + // legacy omitted → modern-only strict (the entryModern arm default): no silent 2025 serving. + const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + await using wired = await wire(transport, greetFactory, modernClient); // The documented strict cell over plain HTTP: a 2025-shaped initialize is // answered with the unsupported-protocol-version error naming the // supported modern revisions (the numeric code is not pinned here). - const response = await fetch(endpoint.baseUrl, { + const response = await wired.fetch!(wired.url!, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify({ @@ -172,32 +112,37 @@ verifies('typescript:hosting:entry:strict-rejects-legacy', async (_args: TestArg expect(body.error.data?.supported).toContain(MODERN); // The plain SDK client sees the same rejection at connect time. - const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); + const plainClient = new Client({ name: 'plain-2025-client', version: '1.0.0' }); try { - await expect(client.connect(new StreamableHTTPClientTransport(endpoint.baseUrl))).rejects.toThrow( + await expect(plainClient.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }))).rejects.toThrow( /Unsupported protocol version|400/ ); } finally { - await client.close().catch(() => {}); + await plainClient.close().catch(() => {}); } }); -verifies('typescript:hosting:entry:notification-202', async ({ protocolVersion }: TestArgs) => { - await using endpoint = await startEndpoint(greetFactory, { legacy: 'stateless' }); +verifies('typescript:hosting:entry:notification-202', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'notify-client', version: '1.0.0' }); + await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } }); // 2025 leg: an envelope-less notification rides the legacy stateless slot. // 2026 leg: the notification carries the per-request envelope and a method // the 2026-07-28 registry defines. const notification = - protocolVersion === LEGACY + transport === 'entryStateless' ? { jsonrpc: '2.0', method: 'notifications/initialized' } : { jsonrpc: '2.0', method: 'notifications/cancelled', - params: { requestId: 'never-issued', reason: 'probe', _meta: modernEnvelope() } + params: { + requestId: 'never-issued', + reason: 'probe', + _meta: modernEnvelopeMeta({ name: 'notify-client', version: '1.0.0' }) + } }; - const response = await fetch(endpoint.baseUrl, { + const response = await wired.fetch!(wired.url!, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, body: JSON.stringify(notification)