Implement MCP 2026-07-28 (#2286)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 69131 additions and 9001 deletions

View file

@ -0,0 +1,272 @@
/**
* The cache-hint surface for cacheable 2026-07-28 results:
*
* - `ServerOptions.cacheHints` (per-operation hints for SDK-built results),
* - `registerResource(..., { cacheHint })` (per-resource hints),
* - configuration-time validation (`RangeError`),
* - precedence, resolved per field: handler-returned values (when valid)
* over the per-resource hint over the per-operation hint over the defaults
* `{ ttlMs: 0, cacheScope: 'private' }`,
* - and the era boundary: 2025-era responses never gain any of it.
*/
import type { JSONRPCMessage, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
InMemoryTransport,
PROTOCOL_VERSION_META_KEY,
setNegotiatedProtocolVersion
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { invoke } from '../../src/server/invoke';
import { McpServer, ResourceTemplate } from '../../src/server/mcp';
import type { ServerOptions } from '../../src/server/server';
import { installModernOnlyHandlers, Server } from '../../src/server/server';
const MODERN_REVISION = '2026-07-28';
const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION };
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'cache-hint-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
const modernRequest = (method: string, params: Record<string, unknown> = {}): JSONRPCRequest =>
({
jsonrpc: '2.0',
id: 1,
method,
params: { ...params, _meta: ENVELOPE }
}) as JSONRPCRequest;
function buildMcpServer(options?: ServerOptions): McpServer {
const mcpServer = new McpServer({ name: 'cache-hint-server', version: '1.0.0' }, options);
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
return mcpServer;
}
async function modernResult(mcpServer: McpServer, request: JSONRPCRequest): Promise<Record<string, unknown>> {
setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION);
const response = await invoke(mcpServer, request, { classification: MODERN });
expect(response.status).toBe(200);
const body = (await response.json()) as { result: Record<string, unknown> };
return body.result;
}
describe('configuration-time validation', () => {
it('rejects a negative ttlMs in ServerOptions.cacheHints with a RangeError', () => {
expect(() => new McpServer({ name: 's', version: '1' }, { cacheHints: { 'tools/list': { ttlMs: -1 } } })).toThrowError(RangeError);
});
it('rejects a non-integer ttlMs and an unknown cacheScope with a RangeError', () => {
expect(() => new Server({ name: 's', version: '1' }, { cacheHints: { 'resources/read': { ttlMs: 1.5 } } })).toThrowError(
RangeError
);
expect(
() => new Server({ name: 's', version: '1' }, { cacheHints: { 'server/discover': { cacheScope: 'shared' as never } } })
).toThrowError(RangeError);
});
it('rejects an invalid registerResource cacheHint with a RangeError', () => {
const mcpServer = buildMcpServer();
expect(() =>
mcpServer.registerResource('bad', 'test://bad', { cacheHint: { ttlMs: -5 } }, async uri => ({
contents: [{ uri: uri.href, text: 'x' }]
}))
).toThrowError(RangeError);
});
});
describe('modern (2026-07-28) responses', () => {
it('fills the defaults when nothing is configured', async () => {
const result = await modernResult(buildMcpServer(), modernRequest('tools/list'));
expect(result).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' });
});
it('uses the per-operation hint from ServerOptions.cacheHints for SDK-built list results', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } } });
const result = await modernResult(mcpServer, modernRequest('tools/list'));
expect(result).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' });
});
it('uses the per-operation hint for server/discover', async () => {
const server = new Server({ name: 'discover-server', version: '1.0.0' }, { cacheHints: { 'server/discover': { ttlMs: 30_000 } } });
installModernOnlyHandlers(server, [MODERN_REVISION]);
setNegotiatedProtocolVersion(server, MODERN_REVISION);
const response = await invoke(server, modernRequest('server/discover'), { classification: MODERN });
expect(response.status).toBe(200);
const body = (await response.json()) as { result: Record<string, unknown> };
expect(body.result).toMatchObject({ resultType: 'complete', ttlMs: 30_000, cacheScope: 'private' });
expect(Array.isArray(body.result['supportedVersions'])).toBe(true);
});
it('uses the per-operation hint for prompts/list', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'prompts/list': { ttlMs: 15_000, cacheScope: 'public' } } });
mcpServer.registerPrompt('greeting', { description: 'Say hello' }, async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }]
}));
const result = await modernResult(mcpServer, modernRequest('prompts/list'));
expect(result).toMatchObject({ resultType: 'complete', ttlMs: 15_000, cacheScope: 'public' });
});
it('uses the per-operation hint for resources/list', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/list': { ttlMs: 20_000 } } });
mcpServer.registerResource('plain', 'test://plain', {}, async uri => ({
contents: [{ uri: uri.href, text: 'plain' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/list'));
expect(result).toMatchObject({ resultType: 'complete', ttlMs: 20_000, cacheScope: 'private' });
});
it('uses the per-operation hint for resources/templates/list', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/templates/list': { ttlMs: 45_000, cacheScope: 'public' } } });
mcpServer.registerResource(
'templated',
new ResourceTemplate('test://things/{id}', { list: undefined }),
{},
async (uri, { id }) => ({ contents: [{ uri: uri.href, text: `id=${String(id)}` }] })
);
const result = await modernResult(mcpServer, modernRequest('resources/templates/list'));
expect(result).toMatchObject({ resultType: 'complete', ttlMs: 45_000, cacheScope: 'public' });
});
it('a per-resource cacheHint wins over the per-operation hint for that resource', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } });
mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({
contents: [{ uri: uri.href, text: 'hinted' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://hinted' }));
expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' });
});
it('the per-operation hint applies to resources registered without their own hint', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } });
mcpServer.registerResource('plain', 'test://plain', {}, async uri => ({
contents: [{ uri: uri.href, text: 'plain' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://plain' }));
expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'private' });
});
it('a per-resource hint setting only cacheScope still takes ttlMs from the per-operation hint (per-field resolution)', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } });
mcpServer.registerResource('scoped', 'test://scoped', { cacheHint: { cacheScope: 'public' } }, async uri => ({
contents: [{ uri: uri.href, text: 'scoped' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://scoped' }));
expect(result).toMatchObject({ ttlMs: 1_000, cacheScope: 'public' });
});
it('a per-resource hint setting only ttlMs still takes cacheScope from the per-operation hint (per-field resolution)', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { cacheScope: 'public' } } });
mcpServer.registerResource('timed', 'test://timed', { cacheHint: { ttlMs: 2_000 } }, async uri => ({
contents: [{ uri: uri.href, text: 'timed' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://timed' }));
expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' });
});
it('when both configured hints set the same fields, the per-resource values win for every field', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000, cacheScope: 'private' } } });
mcpServer.registerResource('full', 'test://full', { cacheHint: { ttlMs: 2_000, cacheScope: 'public' } }, async uri => ({
contents: [{ uri: uri.href, text: 'full' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://full' }));
expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'public' });
});
it('a field neither configured author sets falls back to the default', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } });
mcpServer.registerResource('partial', 'test://partial', { cacheHint: { ttlMs: 2_000 } }, async uri => ({
contents: [{ uri: uri.href, text: 'partial' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://partial' }));
expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'private' });
});
it('fills the defaults for resources/read when neither configured author provides a hint', async () => {
const mcpServer = buildMcpServer();
mcpServer.registerResource('bare', 'test://bare', {}, async uri => ({
contents: [{ uri: uri.href, text: 'bare' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://bare' }));
expect(result).toMatchObject({ ttlMs: 0, cacheScope: 'private' });
});
it('valid handler-returned cache fields win over every configured hint', async () => {
const mcpServer = buildMcpServer({ cacheHints: { 'resources/read': { ttlMs: 1_000 } } });
mcpServer.registerResource('authored', 'test://authored', { cacheHint: { ttlMs: 2_000 } }, async uri => ({
contents: [{ uri: uri.href, text: 'authored' }],
ttlMs: 3_000,
cacheScope: 'public'
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://authored' }));
expect(result).toMatchObject({ ttlMs: 3_000, cacheScope: 'public' });
});
it('invalid handler-returned values fall back to the configured hint', async () => {
const mcpServer = buildMcpServer();
mcpServer.registerResource('invalid', 'test://invalid', { cacheHint: { ttlMs: 2_000 } }, async uri => ({
contents: [{ uri: uri.href, text: 'invalid' }],
ttlMs: -10
}));
const result = await modernResult(mcpServer, modernRequest('resources/read', { uri: 'test://invalid' }));
expect(result).toMatchObject({ ttlMs: 2_000, cacheScope: 'private' });
});
it('never leaks the cacheHint configuration into resources/list entries', async () => {
const mcpServer = buildMcpServer();
mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000 } }, async uri => ({
contents: [{ uri: uri.href, text: 'hinted' }]
}));
const result = await modernResult(mcpServer, modernRequest('resources/list'));
const resources = result['resources'] as Array<Record<string, unknown>>;
expect(resources).toHaveLength(1);
expect('cacheHint' in resources[0]!).toBe(false);
});
});
describe('the 2025 era is never affected', () => {
async function legacyExchange(mcpServer: McpServer, requests: JSONRPCMessage[]): Promise<JSONRPCMessage[]> {
const [peerTx, serverTx] = InMemoryTransport.createLinkedPair();
const sent: JSONRPCMessage[] = [];
peerTx.onmessage = message => void sent.push(message);
await peerTx.start();
await mcpServer.server.connect(serverTx);
for (const request of requests) {
serverTx.onmessage?.(request);
}
await new Promise(resolve => setTimeout(resolve, 10));
await mcpServer.close();
return sent;
}
it('configured cache hints never reach a 2025-era response (no resultType, ttlMs or cacheScope on the wire)', async () => {
const mcpServer = buildMcpServer({
cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, 'resources/read': { ttlMs: 1_000 } }
});
mcpServer.registerResource('hinted', 'test://hinted', { cacheHint: { ttlMs: 2_000 } }, async uri => ({
contents: [{ uri: uri.href, text: 'hinted' }]
}));
const sent = await legacyExchange(mcpServer, [
{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} } as JSONRPCMessage,
{ jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'test://hinted' } } as JSONRPCMessage,
{ jsonrpc: '2.0', id: 3, method: 'resources/list', params: {} } as JSONRPCMessage
]);
expect(sent).toHaveLength(3);
for (const message of sent) {
const json = JSON.stringify(message);
expect(json).not.toContain('"resultType"');
expect(json).not.toContain('"ttlMs"');
expect(json).not.toContain('"cacheScope"');
expect(json).not.toContain('"cacheHint"');
}
});
});

View file

@ -0,0 +1,131 @@
/**
* B-2 rule pin: hand-wired legacy-transport traffic is NEVER
* Protocol-classified.
*
* Discriminator: messages delivered by the hand-wired streamable HTTP server
* transport carry `extra.request` (the HTTP side channel) but `extra.classification`
* stays UNSET — the carrier exists for edge classifiers (the 2026 entry), and
* the Protocol layer must not classify on their behalf. A modern-stamped body
* (full 2026 `_meta` envelope) pushed through a legacy transport gets today's
* exact legacy semantics, byte-identical to the same body without the envelope
* claim where the envelope does not participate (the reserved keys are lifted
* from `_meta`, exactly as for any legacy request carrying them).
*/
import type { MessageExtraInfo } from '@modelcontextprotocol/core-internal';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Server } from '../../src/server/server';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp';
const MODERN = '2026-07-28';
async function setupHandWired() {
const server = new Server({ name: 'pin-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/call', async () => ({ content: [{ type: 'text', text: 'pinned' }] }));
server.setRequestHandler('tools/list', async () => ({ tools: [] }));
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true
});
await server.connect(transport);
// Hand-wired observation point: chain onto the transport callback the same
// way a consumer wrapping the transport would (wrappable-after-connect).
const seen: Array<{ method?: string; extra?: MessageExtraInfo }> = [];
const previous = transport.onmessage;
transport.onmessage = (message, extra) => {
seen.push({ method: (message as { method?: string }).method, extra });
previous?.(message, extra);
};
const post = async (body: unknown): Promise<{ status: number; text: string }> => {
const response = await transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body)
})
);
return { status: response.status, text: await response.text() };
};
return { server, transport, seen, post };
}
const toolsCall = (meta?: Record<string, unknown>) => ({
jsonrpc: '2.0',
id: 7,
method: 'tools/call',
params: {
name: 'anything',
arguments: {},
...(meta !== undefined && { _meta: meta })
}
});
const modernEnvelope = {
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
describe('B-2: hand-wired legacy-transport traffic is never Protocol-classified', () => {
it('extra.request is set and extra.classification stays unset for every delivered message', async () => {
const { server, seen, post } = await setupHandWired();
await post(toolsCall());
await post(toolsCall(modernEnvelope));
expect(seen.length).toBeGreaterThanOrEqual(2);
for (const { extra } of seen) {
expect(extra?.request).toBeInstanceOf(Request);
expect(extra?.classification).toBeUndefined();
}
await server.close();
});
it('a modern-stamped body through the legacy transport gets today’s exact legacy semantics, byte-identical', async () => {
const plainSetup = await setupHandWired();
const plainResponse = await plainSetup.post(toolsCall());
await plainSetup.server.close();
const stampedSetup = await setupHandWired();
const stampedResponse = await stampedSetup.post(toolsCall(modernEnvelope));
await stampedSetup.server.close();
// Byte-identical response: the envelope claim does not flip an era, does
// not change the result shape, does not get echoed back.
expect(stampedResponse.status).toBe(plainResponse.status);
expect(stampedResponse.text).toBe(plainResponse.text);
expect(stampedResponse.text).toContain('pinned');
expect(stampedResponse.text).not.toContain(MODERN);
});
it('a modern-stamped initialize through the legacy transport negotiates exactly like today (no modern era)', async () => {
const { server, post } = await setupHandWired();
const { status, text } = await post({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: MODERN,
capabilities: {},
clientInfo: { name: 'modern-client', version: '1.0.0' },
_meta: modernEnvelope
}
});
expect(status).toBe(200);
const parsed = JSON.parse(text) as { result: { protocolVersion: string } };
// Today's exact legacy semantics: the unknown requested version is
// countered with the latest released version; the body stamp does not
// make the legacy transport modern.
expect(parsed.result.protocolVersion).toBe('2025-11-25');
await server.close();
});
});

View file

@ -0,0 +1,816 @@
/**
* createMcpHandler: the dual-era HTTP entry.
*
* Covers the two legacy postures ('stateless' — the default — and 'reject' →
* modern-only strict), the isLegacyRequest predicate and the user-land routing
* pattern that replaces the removed handler-valued legacy option, the handler
* faces, the per-request era write + client-identity backfill, notification
* routing, the response-mode knob, and close() teardown of the modern leg.
*/
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import type { McpRequestContext } from '../../src/server/createMcpHandler';
import { createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
const MODERN_REVISION = '2026-07-28';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'entry-test-client', version: '3.2.1' },
[CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }
};
interface JSONRPCErrorBody {
jsonrpc: string;
id: unknown;
error: { code: number; message: string; data?: Record<string, unknown> };
}
function modernToolsCall(name: string, args: Record<string, unknown>, envelope: Record<string, unknown> = ENVELOPE): unknown {
return {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args, _meta: envelope }
};
}
/**
* The SEP-2243 standard headers a conformant client derives from the body it
* sends. Only emitted for a body carrying a modern envelope claim, so legacy
* test cells stay byte-untouched; spread before any explicit `headers` so a
* caller that needs to test a stripped or disagreeing header can override.
*/
function bodyDerivedStandardHeaders(body: unknown): Record<string, string> {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return {};
const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record<string, unknown> } };
if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {};
const out: Record<string, string> = {};
if (typeof b.method === 'string') out['mcp-method'] = b.method;
const name = b.method === 'resources/read' ? b.params.uri : b.params.name;
if (typeof name === 'string') out['mcp-name'] = name;
return out;
}
function postRequest(body: unknown, headers: Record<string, string> = {}): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...bodyDerivedStandardHeaders(body),
...headers
},
body: typeof body === 'string' ? body : JSON.stringify(body)
});
}
interface TestFactoryState {
contexts: McpRequestContext[];
products: McpServer[];
oninitializedCalls: number;
}
function testFactory(): { factory: (ctx: McpRequestContext) => McpServer; state: TestFactoryState } {
const state: TestFactoryState = { contexts: [], products: [], oninitializedCalls: 0 };
const factory = (ctx: McpRequestContext): McpServer => {
state.contexts.push(ctx);
const mcpServer = new McpServer({ name: 'entry-test-server', version: '1.0.0' });
mcpServer.server.oninitialized = () => {
state.oninitializedCalls += 1;
};
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx2) => ({
content: [{ type: 'text', text: ctx2.http?.authInfo?.clientId ?? 'anonymous' }]
}));
mcpServer.registerTool('progress-then-echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx2) => {
await ctx2.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } });
return { content: [{ type: 'text', text }] };
});
mcpServer.registerTool('park', { inputSchema: z.object({}) }, async (_args, ctx2) => {
await new Promise<void>(resolve => {
ctx2.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true });
});
return { content: [{ type: 'text', text: 'aborted' }] };
});
state.products.push(mcpServer);
return mcpServer;
};
return { factory, state };
}
describe('createMcpHandler — modern path', () => {
it('serves an envelope-carrying request on a fresh modern instance', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hello' })));
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('hello');
expect(state.contexts).toHaveLength(1);
expect(state.contexts[0]?.era).toBe('modern');
expect(state.contexts[0]?.requestInfo).toBeInstanceOf(Request);
});
it('serves server/discover on the modern path with the modern supported list', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(
postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } })
);
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { supportedVersions: string[]; serverInfo: { name: string } } };
expect(body.result.supportedVersions).toEqual([MODERN_REVISION]);
expect(body.result.serverInfo.name).toBe('entry-test-server');
});
it('backfills the deprecated accessors and the negotiated revision from the validated envelope (per-request instance state)', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' })));
expect(response.status).toBe(200);
const server = state.products[0]!.server;
expect(server.getClientVersion()).toEqual({ name: 'entry-test-client', version: '3.2.1' });
expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } });
expect(server.getNegotiatedProtocolVersion()).toBe(MODERN_REVISION);
});
it('never fires oninitialized on the modern path and never needs setProtocolVersion on the per-request transport', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
// A 2026-classified `notifications/initialized` (modern header, no body claim)
// is acknowledged but the era registry has no such notification, so the
// legacy lifecycle callback structurally cannot fire.
const response = await handler.fetch(
postRequest(
{ jsonrpc: '2.0', method: 'notifications/initialized' },
{ 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'notifications/initialized' }
)
);
expect(response.status).toBe(202);
expect(state.oninitializedCalls).toBe(0);
// The legacy transport's setProtocolVersion side effect is moot by construction:
// the per-request transport does not implement the optional hook at all.
const transport = new PerRequestHTTPServerTransport({ classification: { era: 'modern', revision: MODERN_REVISION } });
expect((transport as { setProtocolVersion?: unknown }).setProtocolVersion).toBeUndefined();
});
it('passes caller-supplied authInfo through to handler context and never derives it from headers', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const withAuth = await handler.fetch(postRequest(modernToolsCall('whoami', {})), {
authInfo: { token: 'verified', clientId: 'client-7', scopes: [] }
});
const withAuthBody = (await withAuth.json()) as { result: { content: Array<{ text: string }> } };
expect(withAuthBody.result.content[0]?.text).toBe('client-7');
const withoutAuth = await handler.fetch(postRequest(modernToolsCall('whoami', {}), { authorization: 'Bearer raw-header-token' }));
const withoutAuthBody = (await withoutAuth.json()) as { result: { content: Array<{ text: string }> } };
expect(withoutAuthBody.result.content[0]?.text).toBe('anonymous');
});
it('answers era-removed and unknown methods with method-not-found over HTTP 404', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const eraRemoved = await handler.fetch(
postRequest({ jsonrpc: '2.0', id: 2, method: 'logging/setLevel', params: { level: 'info', _meta: ENVELOPE } })
);
expect(eraRemoved.status).toBe(404);
const eraRemovedBody = (await eraRemoved.json()) as JSONRPCErrorBody;
expect(eraRemovedBody.error.code).toBe(-32_601);
expect(eraRemovedBody.id).toBe(2);
const unknown = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 3, method: 'no/such-method', params: { _meta: ENVELOPE } }));
expect(unknown.status).toBe(404);
const unknownBody = (await unknown.json()) as JSONRPCErrorBody;
expect(unknownBody.error.code).toBe(-32_601);
expect(unknownBody.id).toBe(3);
});
it('rejects an envelope claiming a revision the endpoint does not serve with the supported list', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(
postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' }))
);
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_022);
expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]);
expect(body.error.data?.['requested']).toBe('2030-01-01');
expect(body.id).toBe(1);
expect(state.contexts).toHaveLength(0);
});
it('rejects a header/body protocol-version mismatch with -32020 (HeaderMismatch) over HTTP 400', async () => {
const { factory } = testFactory();
const onerror = vi.fn();
const handler = createMcpHandler(factory, { onerror });
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' }));
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_020);
// The rejection echoes the request id.
expect(body.id).toBe(1);
expect(onerror).toHaveBeenCalled();
});
it('rejects a modern-classified request without a _meta envelope with -32602 naming the missing key over HTTP 400', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
// The MCP-Protocol-Version header names the modern revision but the body
// carries no per-request envelope: invalid params naming what is missing,
// not a version error and not silent legacy serving.
const response = await handler.fetch(
postRequest(
{ jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} },
{ 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' }
)
);
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_602);
expect(JSON.stringify(body.error.data)).toContain('_meta');
expect(body.id).toBe(11);
expect(state.contexts).toHaveLength(0);
});
it('answers entry-internal failures with 500/-32603 and reports them through onerror', async () => {
const onerror = vi.fn();
const handler = createMcpHandler(
() => {
throw new Error('factory exploded');
},
{ onerror }
);
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' })));
expect(response.status).toBe(500);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_603);
expect(body.id).toBe(1);
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' }));
});
it('closes and releases the per-request instance when a modern exchange fails internally', async () => {
const { factory, state } = testFactory();
const onerror = vi.fn();
let closeCalls = 0;
const failingFactory = (ctx: McpRequestContext): McpServer => {
const product = factory(ctx);
vi.spyOn(product.server, 'connect').mockRejectedValue(new Error('connect exploded'));
const realClose = product.server.close.bind(product.server);
product.server.close = async () => {
closeCalls += 1;
await realClose();
};
return product;
};
const handler = createMcpHandler(failingFactory, { onerror });
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' })));
expect(response.status).toBe(500);
expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603);
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'connect exploded' }));
expect(state.contexts).toHaveLength(1);
// The failed exchange's instance was closed and released from the
// in-flight set: the handler's own close() finds nothing to tear down.
expect(closeCalls).toBe(1);
await handler.close();
expect(closeCalls).toBe(1);
});
it('rejects a malformed envelope behind a present claim with invalid params naming the offending key', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(
postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }))
);
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_602);
expect(JSON.stringify(body.error.data)).toContain('clientInfo');
expect(body.id).toBe(1);
expect(state.contexts).toHaveLength(0);
});
});
describe("createMcpHandler — modern-only strict (legacy: 'reject')", () => {
it('rejects envelope-less requests with the unsupported-protocol-version error and the supported list', async () => {
const { factory, state } = testFactory();
const onerror = vi.fn();
const handler = createMcpHandler(factory, { legacy: 'reject', onerror });
const response = await handler.fetch(
postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'x' } } })
);
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_022);
expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]);
expect(body.id).toBe(1);
expect(state.contexts).toHaveLength(0);
expect(onerror).toHaveBeenCalled();
});
it('rejects an envelope-less initialize naming the supported and requested versions', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
const response = await handler.fetch(
postRequest({
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} }
})
);
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_022);
expect(body.error.data?.['supported']).toEqual([MODERN_REVISION]);
expect(body.error.data?.['requested']).toBe('2025-11-25');
expect(body.id).toBe('init-1');
});
it('answers GET and DELETE with 405 Method not allowed', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
for (const method of ['GET', 'DELETE']) {
const response = await handler.fetch(new Request('http://localhost/mcp', { method }));
expect(response.status).toBe(405);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toBe('Method not allowed.');
// Body-less methods carry no request id to echo.
expect(body.id).toBeNull();
}
});
it('rejects batch and response-body POSTs as invalid requests', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
const batch = await handler.fetch(postRequest([{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }]));
expect(batch.status).toBe(400);
const batchBody = (await batch.json()) as JSONRPCErrorBody;
expect(batchBody.error.code).toBe(-32_600);
// A whole-array rejection corresponds to no single request: id stays null.
expect(batchBody.id).toBeNull();
const responseBody = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } }));
expect(responseBody.status).toBe(400);
const responseBodyJson = (await responseBody.json()) as JSONRPCErrorBody;
expect(responseBodyJson.error.code).toBe(-32_600);
// A posted response is not a request; there is no request id to echo.
expect(responseBodyJson.id).toBeNull();
});
it('answers unparseable JSON with a parse error', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
const response = await handler.fetch(postRequest('{not json'));
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_700);
// The id could not be read from the malformed body, so it stays null.
expect(body.id).toBeNull();
});
it('acknowledges and drops legacy-classified notifications (202, never dispatched)', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
const response = await handler.fetch(
postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }, { 'mcp-method': 'something/else' })
);
expect(response.status).toBe(202);
expect(await response.text()).toBe('');
// Never dispatched: no instance was even constructed, and the Mcp-Method
// header is never enforced on legacy notifications.
expect(state.contexts).toHaveLength(0);
});
it('routes a notification POST by the modern header when the body carries no claim', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
const response = await handler.fetch(
postRequest(
{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } },
{ 'mcp-protocol-version': MODERN_REVISION }
)
);
expect(response.status).toBe(202);
expect(state.contexts).toHaveLength(1);
expect(state.contexts[0]?.era).toBe('modern');
});
it('names the modern revisions in the strict rejection data so legacy clients can discover the endpoint era', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'reject' });
const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }));
const body = (await response.json()) as JSONRPCErrorBody;
// The strict rejection deliberately names the modern revisions so a legacy
// client can discover what the endpoint serves from the error alone.
expect(JSON.stringify(body.error.data)).toContain(MODERN_REVISION);
});
});
describe('createMcpHandler — stateless legacy fallback (the default)', () => {
it('serves a 2025-era client by default through the frozen stateless idiom with a fresh instance per request', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const initialize = await handler.fetch(
postRequest({
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' }, capabilities: {} }
})
);
expect(initialize.status).toBe(200);
expect(await initialize.text()).toContain('"protocolVersion":"2025-11-25"');
const toolsCall = await handler.fetch(
postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: { text: 'legacy hello' } } })
);
expect(toolsCall.status).toBe(200);
expect(await toolsCall.text()).toContain('legacy hello');
expect(state.contexts).toHaveLength(2);
expect(state.contexts.every(ctx => ctx.era === 'legacy')).toBe(true);
expect(state.products[0]).not.toBe(state.products[1]);
// Hand-shaped legacy serving never marks instances as modern.
expect(state.products[0]!.server.getNegotiatedProtocolVersion()).not.toBe(MODERN_REVISION);
});
it("serves the same legacy traffic when 'stateless' is passed explicitly (the explicit value of the default)", async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory, { legacy: 'stateless' });
const initialize = await handler.fetch(
postRequest({
jsonrpc: '2.0',
id: 'init-2',
method: 'initialize',
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' }, capabilities: {} }
})
);
expect(initialize.status).toBe(200);
expect(await initialize.text()).toContain('"protocolVersion":"2025-11-25"');
expect(state.contexts[0]?.era).toBe('legacy');
});
it('answers GET and DELETE like the canonical stateless example (405, Method not allowed.)', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
for (const method of ['GET', 'DELETE']) {
const response = await handler.fetch(new Request('http://localhost/mcp', { method }));
expect(response.status).toBe(405);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toBe('Method not allowed.');
}
});
it('routes legacy notification POSTs to the legacy leg (202 acknowledged by the stateless transport)', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }));
expect(response.status).toBe(202);
expect(state.contexts).toHaveLength(1);
expect(state.contexts[0]?.era).toBe('legacy');
});
it('routes all-legacy batch arrays to the legacy leg unchanged', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(
postRequest([
{ jsonrpc: '2.0', method: 'notifications/initialized' },
{ jsonrpc: '2.0', method: 'notifications/roots/list_changed' }
])
);
expect(response.status).toBe(202);
});
it('hands unparseable bodies to the legacy leg so the parse error stays the legacy transport answer', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(postRequest('{not json'));
expect(response.status).toBe(400);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_700);
});
it('still serves the modern path on the same endpoint (one factory, both legs)', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'modern hello' })));
expect(modern.status).toBe(200);
expect(await modern.text()).toContain('modern hello');
expect(state.contexts[0]?.era).toBe('modern');
});
it("reports legacy-leg failures through the entry's onerror instead of swallowing them", async () => {
const onerror = vi.fn();
const handler = createMcpHandler(
ctx => {
if (ctx.era === 'legacy') {
throw new Error('legacy factory exploded');
}
return new McpServer({ name: 'modern-only-product', version: '1.0.0' });
},
{ onerror }
);
const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }));
expect(response.status).toBe(500);
expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603);
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'legacy factory exploded' }));
});
it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with the fallback active)', async () => {
const { factory, state } = testFactory();
const handler = createMcpHandler(factory);
// Parsed-but-not-JSON-RPC single object: the entry's -32600, not the
// legacy transport's -32700.
const notJsonRpc = await handler.fetch(postRequest({ hello: 'world' }));
expect(notJsonRpc.status).toBe(400);
expect(((await notJsonRpc.json()) as JSONRPCErrorBody).error.code).toBe(-32_600);
// Empty batch: the entry's -32600/400, not the legacy leg's 202 ack.
const emptyBatch = await handler.fetch(postRequest([]));
expect(emptyBatch.status).toBe(400);
expect(((await emptyBatch.json()) as JSONRPCErrorBody).error.code).toBe(-32_600);
// A batch containing an invalid element is rejected on both arms (element-wise classification).
const mixedBatch = await handler.fetch(postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }, { nope: true }]));
expect(mixedBatch.status).toBe(400);
expect(((await mixedBatch.json()) as JSONRPCErrorBody).error.code).toBe(-32_600);
// The legacy leg is never consulted for these cells.
expect(state.contexts).toHaveLength(0);
});
it('answers a legacy-direction server/discover with a plain method-not-found and zero 2026 vocabulary', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }));
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toContain('-32601');
expect(text).toContain('Method not found');
expect(text).not.toContain('2026');
});
});
describe('createMcpHandler — user-land routing with isLegacyRequest (replaces the handler-valued legacy option)', () => {
it('routes legacy traffic to an existing handler with the original bytes untouched, alongside a strict modern entry', async () => {
const { factory, state } = testFactory();
const original = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
let receivedBody: string | undefined;
const existingLegacyHandler = vi.fn(async (request: Request) => {
receivedBody = await request.text();
return new Response('legacy-served', { status: 299 });
});
const modern = createMcpHandler(factory, { legacy: 'reject' });
// The documented routing pattern: the predicate decides, the strict
// entry serves everything that is not legacy.
const route = async (request: Request): Promise<Response> => {
if (await isLegacyRequest(request)) {
return existingLegacyHandler(request);
}
return modern.fetch(request);
};
// A claim-less 2025 request reaches the existing handler with its body
// still readable — the predicate classifies a clone, never the original.
const response = await route(postRequest(original));
expect(response.status).toBe(299);
expect(await response.text()).toBe('legacy-served');
expect(receivedBody).toBe(JSON.stringify(original));
// GET/DELETE are method-routed to the existing handler too (sessionful wirings own them).
const get = await route(new Request('http://localhost/mcp', { method: 'GET' }));
expect(get.status).toBe(299);
// Modern envelope traffic never reaches the legacy handler.
const modernResponse = await route(postRequest(modernToolsCall('echo', { text: 'hi' })));
expect(modernResponse.status).toBe(200);
expect(existingLegacyHandler).toHaveBeenCalledTimes(2);
expect(state.contexts.filter(ctx => ctx.era === 'modern')).toHaveLength(1);
// A malformed modern claim is NOT legacy: it goes to the modern entry,
// which answers the validation-ladder error (-32602), never the legacy handler.
const malformed = await route(
postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }))
);
expect(malformed.status).toBe(400);
expect(((await malformed.json()) as JSONRPCErrorBody).error.code).toBe(-32_602);
expect(existingLegacyHandler).toHaveBeenCalledTimes(2);
});
it('isLegacyRequest agrees with the entry classification rung across the routing cells', async () => {
const legacyShaped: Array<{ name: string; request: () => Request }> = [
{
name: 'claim-less request',
request: () => postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
},
{
name: 'initialize handshake',
request: () =>
postRequest({
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} }
})
},
{ name: 'claim-less notification', request: () => postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }) },
{ name: 'GET session operation', request: () => new Request('http://localhost/mcp', { method: 'GET' }) },
{ name: 'DELETE session operation', request: () => new Request('http://localhost/mcp', { method: 'DELETE' }) },
{
name: 'all-legacy batch array',
request: () => postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }])
},
{ name: 'posted JSON-RPC response', request: () => postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } }) },
{ name: 'unparseable body', request: () => postRequest('{not json') },
{
name: 'claim-less server/discover (no envelope, classified like any other claim-less request)',
request: () => postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} })
}
];
const modernShaped: Array<{ name: string; request: () => Request }> = [
{ name: 'valid modern envelope', request: () => postRequest(modernToolsCall('echo', { text: 'x' })) },
{
name: 'enveloped server/discover probe',
request: () => postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } })
},
{
name: 'envelope claiming an unsupported revision (modern path answers -32022)',
request: () =>
postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' }))
},
{
name: 'malformed envelope behind a present claim (modern path answers -32602)',
request: () => postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION }))
},
{
name: 'modern header without a claim (modern path answers -32602)',
request: () =>
postRequest(
{ jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} },
{ 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' }
)
},
{
name: 'header/body mismatch (modern path answers -32020)',
request: () => postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' })
}
];
for (const { name, request } of legacyShaped) {
expect(await isLegacyRequest(request()), name).toBe(true);
}
for (const { name, request } of modernShaped) {
expect(await isLegacyRequest(request()), name).toBe(false);
}
});
it('leaves the request body readable and accepts a pre-parsed body without reading the stream', async () => {
const original = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} };
// Body stays readable after the predicate ran (it classified a clone).
const request = postRequest(original);
expect(await isLegacyRequest(request)).toBe(true);
expect(request.bodyUsed).toBe(false);
expect(await request.text()).toBe(JSON.stringify(original));
// With a pre-parsed body the request stream is never touched at all.
const preParsed = postRequest(original);
expect(await isLegacyRequest(preParsed, original)).toBe(true);
expect(preParsed.bodyUsed).toBe(false);
expect(await isLegacyRequest(postRequest(modernToolsCall('echo', { text: 'x' })), modernToolsCall('echo', { text: 'x' }))).toBe(
false
);
});
it("throws a TypeError at construction when a handler function is passed as the 'legacy' option", () => {
const { factory } = testFactory();
const myExistingLegacyHandler = async (): Promise<Response> => new Response(null, { status: 200 });
const construct = () => createMcpHandler(factory, { legacy: myExistingLegacyHandler as unknown as 'stateless' });
expect(construct).toThrow(TypeError);
expect(construct).toThrow(/isLegacyRequest/);
});
});
describe('createMcpHandler — responseMode', () => {
it('defaults to the lazy upgrade: a handler emitting a related notification streams the exchange over SSE', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const response = await handler.fetch(postRequest(modernToolsCall('progress-then-echo', { text: 'streamed' })));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/event-stream');
const text = await response.text();
expect(text).toContain('notifications/progress');
expect(text).toContain('streamed');
});
it("responseMode: 'json' never streams and drops mid-call notifications", async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { responseMode: 'json' });
const response = await handler.fetch(postRequest(modernToolsCall('progress-then-echo', { text: 'json only' })));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('application/json');
const text = await response.text();
expect(text).not.toContain('notifications/progress');
expect(text).toContain('json only');
});
it("responseMode: 'sse' streams even when the handler emits nothing before its result", async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory, { responseMode: 'sse' });
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'eager stream' })));
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/event-stream');
expect(await response.text()).toContain('eager stream');
});
});
describe('createMcpHandler — handler faces', () => {
it('exposes a detach-safe fetch face', async () => {
const { factory } = testFactory();
const { fetch: detachedFetch } = createMcpHandler(factory);
const response = await detachedFetch(postRequest(modernToolsCall('echo', { text: 'detached' })));
expect(response.status).toBe(200);
expect(await response.text()).toContain('detached');
});
// The Node `(req, res, parsedBody?)` adaptation moved to
// `toNodeHandler(handler)` in `@modelcontextprotocol/node`; its conversion
// semantics (stream read, pre-parsed body, req.auth pass-through, HTTP/2
// pseudo-headers, write backpressure) are pinned at unit level there.
});
describe('createMcpHandler — close()', () => {
it('aborts in-flight modern exchanges and refuses further requests', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
const pending = handler.fetch(postRequest(modernToolsCall('park', {})));
// Give the exchange time to reach the parked handler before tearing down.
await new Promise(resolve => setTimeout(resolve, 50));
await handler.close();
const response = await pending;
expect(response.status).toBe(499);
await expect(handler.fetch(postRequest(modernToolsCall('echo', { text: 'late' })))).rejects.toThrow(/closed/);
});
it('leaves the legacy fallback untouched by close() until the handler itself refuses requests', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
await handler.close();
await expect(handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).rejects.toThrow(/closed/);
});
});
// Type-level pin: a zero-argument factory stays assignable to McpServerFactory unchanged.
const zeroArgFactory = () => new McpServer({ name: 'zero-arg', version: '1.0.0' });
void createMcpHandler(zeroArgFactory);

View file

@ -0,0 +1,103 @@
/**
* The pre-dispatch client-capability gate at the HTTP entry: a request to a
* method that requires a client capability the request's envelope did not
* declare is refused with the typed `-32021` error and HTTP 400, before any
* server instance is constructed or dispatched.
*
* No request method served on the 2026-07-28 registry has a static
* requirement today, so these tests drive the gate by adding (and removing) a
* temporary entry to the requirement table; the production behavior with the
* empty table — every modern request passes the gate — is pinned too.
*/
import type { ClientCapabilities } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
REQUIRED_CLIENT_CAPABILITIES_BY_METHOD
} from '@modelcontextprotocol/core-internal';
import { afterEach, describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
const MODERN_REVISION = '2026-07-28';
const envelope = (clientCapabilities: ClientCapabilities) => ({
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'gate-test-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: clientCapabilities
});
function postEcho(clientCapabilities: ClientCapabilities): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'tools/call',
'mcp-name': 'echo'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 7,
method: 'tools/call',
params: { name: 'echo', arguments: { text: 'hi' }, _meta: envelope(clientCapabilities) }
})
});
}
function factory(): McpServer {
const mcpServer = new McpServer({ name: 'gate-test-server', version: '1.0.0' });
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
return mcpServer;
}
const requirementTable = REQUIRED_CLIENT_CAPABILITIES_BY_METHOD as Record<string, ClientCapabilities>;
afterEach(() => {
delete requirementTable['tools/call'];
});
describe('the pre-dispatch client-capability gate', () => {
it('serves modern requests normally while no requirement applies (the table is empty in production)', async () => {
const handler = createMcpHandler(factory);
const response = await handler.fetch(postEcho({}));
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('hi');
});
it('refuses a request missing a required capability with -32021 and HTTP 400, echoing the request id', async () => {
requirementTable['tools/call'] = { sampling: {} };
let factoryRan = false;
const handler = createMcpHandler(() => {
factoryRan = true;
return factory();
});
const response = await handler.fetch(postEcho({ elicitation: {} }));
expect(response.status).toBe(400);
const body = (await response.json()) as {
id: unknown;
error: { code: number; data?: { requiredCapabilities?: ClientCapabilities } };
};
expect(body.error.code).toBe(-32_021);
expect(body.error.data?.requiredCapabilities).toEqual({ sampling: {} });
expect(body.id).toBe(7);
// Pre-dispatch: the refusal happens before any per-request instance exists.
expect(factoryRan).toBe(false);
});
it('serves the request once the required capability is declared in the envelope', async () => {
requirementTable['tools/call'] = { sampling: {} };
const handler = createMcpHandler(factory);
const response = await handler.fetch(postEcho({ sampling: {} }));
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('hi');
});
});

View file

@ -0,0 +1,238 @@
/**
* createMcpHandler — entry-handled `subscriptions/listen` router.
*
* Covers ack-first (the acknowledged notification is the first frame),
* subscription-id stamping (the listen request's JSON-RPC id verbatim),
* per-stream filtering (un-requested types provably never delivered),
* notify sugar, capacity guard, capability-narrowed honored filter, and
* teardown.
*/
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
SUBSCRIPTION_ID_META_KEY
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: '2026-07-28',
[CLIENT_INFO_META_KEY]: { name: 'listen-test-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
function listenRequest(id: string | number, filter: Record<string, unknown>): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({
jsonrpc: '2.0',
id,
method: 'subscriptions/listen',
params: { _meta: ENVELOPE, notifications: filter }
})
});
}
/** Read N SSE `event: message` payloads from a streaming response, then cancel. */
async function readMessages(response: Response, n: number): Promise<unknown[]> {
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
const messages: unknown[] = [];
while (messages.length < n) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const dataLine = frame.split('\n').find(l => l.startsWith('data: '));
if (dataLine) messages.push(JSON.parse(dataLine.slice(6)));
}
}
await reader.cancel();
return messages;
}
function trivialFactory(): () => McpServer {
// Declare every listChanged / subscribe bit so the tests below see the
// requested filter honored as-is (the entry now narrows the ack against
// the per-serve instance's declared capabilities).
return () =>
new McpServer(
{ name: 'listen-test-server', version: '1.0.0' },
{
capabilities: {
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true, subscribe: true }
}
}
);
}
describe('createMcpHandler — subscriptions/listen', () => {
it('serves listen at the entry, consulting the factory only for its declared capabilities', async () => {
let factoryCalls = 0;
let connectCalls = 0;
let closeCalls = 0;
const handler = createMcpHandler(
() => {
factoryCalls++;
const s = new McpServer({ name: 's', version: '1' });
const { connect, close } = s;
s.connect = tx => {
connectCalls++;
return connect.call(s, tx);
};
s.close = () => {
closeCalls++;
return close.call(s);
};
return s;
},
{ keepAliveMs: 0 }
);
const response = await handler.fetch(listenRequest(1, { toolsListChanged: true }));
expect(response.status).toBe(200);
const [ack] = await readMessages(response, 1);
// The factory is consulted exactly once (capabilities probe only); the
// instance is never connected and is closed immediately after the
// capabilities read so a factory-allocated resource cannot leak.
expect(factoryCalls).toBe(1);
expect(connectCalls).toBe(0);
expect(closeCalls).toBe(1);
expect((ack as { method: string }).method).toBe('notifications/subscriptions/acknowledged');
await handler.close();
});
it('ack is the first frame, stamped with the listen id verbatim, carrying the honored subset', async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 });
const response = await handler.fetch(listenRequest('sub-42', { toolsListChanged: true, promptsListChanged: false }));
const [ack] = await readMessages(response, 1);
expect(ack).toEqual({
jsonrpc: '2.0',
method: 'notifications/subscriptions/acknowledged',
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'sub-42' }, notifications: { toolsListChanged: true } }
});
await handler.close();
});
it('delivers only opted-in change types, each stamped with the subscription id', async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 });
const response = await handler.fetch(listenRequest(7, { toolsListChanged: true, resourceSubscriptions: ['file:///a'] }));
// Publish before reading: a stream that did NOT opt in to prompts must
// never see the prompts notification (provably-never-delivered).
handler.notify.promptsChanged();
handler.notify.toolsChanged();
handler.notify.resourceUpdated('file:///b');
handler.notify.resourceUpdated('file:///a');
const messages = (await readMessages(response, 3)) as { method: string; params: Record<string, unknown> }[];
expect(messages.map(m => m.method)).toEqual([
'notifications/subscriptions/acknowledged',
'notifications/tools/list_changed',
'notifications/resources/updated'
]);
expect(messages[2]!.params).toEqual({ _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 }, uri: 'file:///a' });
for (const m of messages) {
expect((m.params['_meta'] as Record<string, unknown>)[SUBSCRIPTION_ID_META_KEY]).toBe(7);
}
await handler.close();
});
it("refuses pre-ack with -32603 'Subscription limit reached' when at capacity", async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0, maxSubscriptions: 1 });
const first = await handler.fetch(listenRequest(1, { toolsListChanged: true }));
expect(first.headers.get('Content-Type')).toBe('text/event-stream');
const second = await handler.fetch(listenRequest(2, { toolsListChanged: true }));
expect(second.headers.get('Content-Type')).toContain('application/json');
const body = (await second.json()) as { error: { code: number; message: string }; id: unknown };
expect(body.error.code).toBe(-32_603);
expect(body.error.message).toBe('Subscription limit reached');
expect(body.id).toBe(2);
await first.body!.cancel();
await handler.close();
});
it("rejects with -32602 when params.notifications is absent (spec marks 'notifications' REQUIRED)", async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 });
const response = await handler.fetch(
new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } })
})
);
expect(response.headers.get('Content-Type')).toContain('application/json');
const body = (await response.json()) as { error: { code: number; message: string }; id: unknown };
expect(body.error.code).toBe(-32_602);
expect(body.error.message).toContain("'notifications' is required");
expect(body.id).toBe(9);
await handler.close();
});
it('handler.close() emits the empty subscriptions/listen result, then closes the stream (graceful-close signal)', async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 });
const response = await handler.fetch(listenRequest(1, { toolsListChanged: true }));
const reader = response.body!.getReader();
// First frame is the ack.
await reader.read();
await handler.close();
// Graceful-close termination: the SubscriptionsListenResult is the
// final SSE frame, then the stream ends.
let resultFrame: unknown;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const text = new TextDecoder().decode(value);
const dataLine = text.split('\n').find(l => l.startsWith('data: '));
if (dataLine) {
const message = JSON.parse(dataLine.slice(6)) as Record<string, unknown>;
if ('result' in message) resultFrame = message;
}
}
expect(resultFrame).toEqual({
jsonrpc: '2.0',
id: 1,
result: { resultType: 'complete', _meta: { 'io.modelcontextprotocol/subscriptionId': 1 } }
});
});
it('legacy-classified listen never reaches the entry listen router (no ack delivered)', async () => {
const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 });
// No envelope claim → classified legacy → dispatched through the
// stateless fallback's Server, where `subscriptions/listen` is not in
// the 2025 registry → −32601 in-band (the legacy transport may stream
// it as a single SSE frame).
const response = await handler.fetch(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'subscriptions/listen',
params: { notifications: { toolsListChanged: true } }
})
})
);
const text = await response.text();
expect(text).not.toContain('notifications/subscriptions/acknowledged');
expect(text).toContain('-32601');
await handler.close();
});
});

View file

@ -0,0 +1,83 @@
/**
* Wire-level continuity twin for the "Unsupported protocol version" rejection,
* exercised through `createMcpHandler(factory, { legacy: 'stateless' })`.
*
* The legacy fallback routes 2025-era traffic through the untouched streamable
* HTTP transport, so the rejection site (and therefore the wire bytes deployed
* clients sniff — see streamableHttpUnsupportedVersionLiteral.test.ts for the
* go-sdk substring dependency) is the same one the standalone transport test
* pins. This twin asserts the bytes hold on the sugar path itself: HTTP 400,
* code -32000, and the literal substring `Unsupported protocol version`, with
* the supported-versions suffix derived from `SUPPORTED_PROTOCOL_VERSIONS`.
*/
import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
interface JSONRPCErrorBody {
jsonrpc: string;
id: unknown;
error: { code: number; message: string };
}
function factory(): McpServer {
const mcpServer = new McpServer({ name: 'literal-twin', version: '1.0.0' });
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
return mcpServer;
}
function postRequest(body: unknown, headers: Record<string, string> = {}): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...headers
},
body: JSON.stringify(body)
});
}
describe('createMcpHandler legacy:"stateless" — unsupported protocol version wire literal continuity', () => {
it('rejects an unsupported MCP-Protocol-Version header with HTTP 400, code -32000, and the sniffed literal substring', async () => {
const handler = createMcpHandler(factory, { legacy: 'stateless' });
// The probe header is an unsupported 2025-era version string: that is what a
// deployed 2025 client can actually send. (A 2026-or-later header on a body
// without an envelope claim is a header/body cross-check disagreement and is
// answered by the classifier before legacy serving is reached.)
const response = await handler.fetch(
postRequest({ jsonrpc: '2.0', id: 'tools-1', method: 'tools/list', params: {} }, { 'mcp-protocol-version': '2024-01-01' })
);
expect(response.status).toBe(400);
expect(response.headers.get('content-type')).toContain('application/json');
const rawBody = await response.text();
// The substring deployed clients (go-sdk) sniff must appear verbatim in the wire bytes.
expect(rawBody).toContain('Unsupported protocol version');
const body = JSON.parse(rawBody) as JSONRPCErrorBody;
expect(body.jsonrpc).toBe('2.0');
expect(body.id).toBeNull();
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toBe(
`Bad Request: Unsupported protocol version: 2024-01-01 (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})`
);
});
it('keeps serving supported 2025-era traffic on the same path (the rejection is header-keyed, not blanket)', async () => {
const handler = createMcpHandler(factory, { legacy: 'stateless' });
const response = await handler.fetch(
postRequest({ jsonrpc: '2.0', id: 'tools-1', method: 'tools/list', params: {} }, { 'mcp-protocol-version': '2025-11-25' })
);
expect(response.status).toBe(200);
expect(await response.text()).toContain('"tools"');
});
});

View file

@ -0,0 +1,210 @@
/**
* `server/discover` machinery + era-aware supported-version list semantics:
*
* - the handler is installed ONLY when the server's supported-versions list
* carries a modern (2026-07-28+) revision; default servers keep answering
* -32601 byte-identically to the deployed fleet
* - the advertisement is modern-only (DV-30) and carries the
* listChanged/subscribe-class capabilities (the spec keeps the bits at
* 2026-07-28; A11 rider discharged with the subscriptions/listen milestone)
* - counter-offer ordering: with era-aware list semantics in place, a legacy
* initialize can never meet a modern version string at the counter-offer
* site, even when the supported list carries one — the guard that must hold
* BEFORE any LATEST/SUPPORTED constant bump.
*
* Era is instance state: an inbound `server/discover` is served only by a
* modern-era instance (the method is physically absent from the legacy
* registry). Production marking of modern instances is owned by the
* server-entry milestone; these tests mark instances through the
* package-internal hook the entry will use, and the modern-era request shape
* carries the required per-request `_meta` envelope.
*/
import type { DiscoverResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
DiscoverResultSchema,
InitializeResultSchema,
InMemoryTransport,
isJSONRPCErrorResponse,
isJSONRPCResultResponse,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY,
setNegotiatedProtocolVersion,
SUPPORTED_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { discoverAdvertisedCapabilities, Server } from '../../src/server/server';
const MODERN = '2026-07-28';
/** A supported list spanning both eras — what the constant becomes after a future bump. */
const DUAL_ERA_VERSIONS = [MODERN, ...SUPPORTED_PROTOCOL_VERSIONS];
async function sendRaw(server: Server, request: JSONRPCRequest, options?: { markModern?: boolean }): Promise<JSONRPCMessage> {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
if (options?.markModern) {
// Stand-in for the modern-era server entry (instance binding): mark the
// instance as serving the modern era so the era gate admits the method.
setNegotiatedProtocolVersion(server, MODERN);
}
const responsePromise = new Promise<JSONRPCMessage>(resolve => {
clientTransport.onmessage = msg => resolve(msg);
});
await clientTransport.start();
await clientTransport.send(request);
return responsePromise;
}
/** A wire-true modern discover request: the 2026 era requires the per-request `_meta` envelope. */
const discoverRequest: JSONRPCRequest = {
jsonrpc: '2.0',
id: 1,
method: 'server/discover',
params: {
_meta: {
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_INFO_META_KEY]: { name: 'test-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
}
}
};
const initializeRequest = (requestedVersion: string): JSONRPCRequest => ({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: requestedVersion, capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' } }
});
describe('server/discover handler gating', () => {
it('a default (legacy-only) server answers server/discover with -32601, byte-identical to the deployed fleet', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
const response = await sendRaw(server, discoverRequest);
expect(isJSONRPCErrorResponse(response)).toBe(true);
if (isJSONRPCErrorResponse(response)) {
expect(response.error.code).toBe(-32_601);
}
await server.close();
});
it('a server with a modern revision in its supported list serves discover on a modern-era instance', async () => {
const server = new Server(
{ name: 'modern-server', version: '2.0.0' },
{ capabilities: { tools: {} }, supportedProtocolVersions: DUAL_ERA_VERSIONS, instructions: 'hello' }
);
const response = await sendRaw(server, discoverRequest, { markModern: true });
expect(isJSONRPCResultResponse(response)).toBe(true);
if (isJSONRPCResultResponse(response)) {
const result = DiscoverResultSchema.parse(response.result);
expect(result.supportedVersions).toEqual([MODERN]);
expect(result.serverInfo).toEqual({ name: 'modern-server', version: '2.0.0' });
expect(result.instructions).toBe('hello');
}
await server.close();
});
it('a modern-era instance whose supported list carries no modern revision still answers -32601 (handler not installed)', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
const response = await sendRaw(server, discoverRequest, { markModern: true });
expect(isJSONRPCErrorResponse(response)).toBe(true);
if (isJSONRPCErrorResponse(response)) {
expect(response.error.code).toBe(-32_601);
}
await server.close();
});
});
describe('discover advertisement constraints', () => {
it('advertises modern-only versions (DV-30): no 2025-era string ever appears in supportedVersions', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS });
const response = await sendRaw(server, discoverRequest, { markModern: true });
if (!isJSONRPCResultResponse(response)) throw new Error('expected result');
const result = DiscoverResultSchema.parse(response.result);
expect(result.supportedVersions).toEqual([MODERN]);
for (const version of result.supportedVersions) {
expect(version >= MODERN).toBe(true);
}
await server.close();
});
it('advertises listChanged/subscribe-class capabilities (A11 rider discharged: subscriptions/listen is served)', async () => {
const server = new Server(
{ name: 'test', version: '1.0.0' },
{
capabilities: {
tools: { listChanged: true },
prompts: { listChanged: true },
resources: { listChanged: true, subscribe: true },
logging: {},
completions: {}
},
supportedProtocolVersions: DUAL_ERA_VERSIONS
}
);
const response = await sendRaw(server, discoverRequest, { markModern: true });
if (!isJSONRPCResultResponse(response)) throw new Error('expected result');
const result = DiscoverResultSchema.parse(response.result) as DiscoverResult;
expect(result.capabilities.tools).toEqual({ listChanged: true });
expect(result.capabilities.prompts).toEqual({ listChanged: true });
expect(result.capabilities.resources).toEqual({ listChanged: true, subscribe: true });
expect(result.capabilities.logging).toEqual({});
expect(result.capabilities.completions).toEqual({});
await server.close();
});
it('discoverAdvertisedCapabilities is pure and leaves the initialize advertisement untouched', async () => {
const capabilities = { tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } };
const advertised = discoverAdvertisedCapabilities(capabilities);
expect(advertised).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } });
// No mutation / aliasing of the input.
expect(advertised).not.toBe(capabilities);
expect(capabilities).toEqual({ tools: { listChanged: true }, resources: { subscribe: true, listChanged: true } });
// The legacy initialize advertisement still carries the full capability set.
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities, supportedProtocolVersions: DUAL_ERA_VERSIONS });
const response = await sendRaw(server, initializeRequest(LATEST_PROTOCOL_VERSION));
if (!isJSONRPCResultResponse(response)) throw new Error('expected result');
const result = InitializeResultSchema.parse(response.result);
expect(result.capabilities.tools).toEqual({ listChanged: true });
expect(result.capabilities.resources).toEqual({ subscribe: true, listChanged: true });
await server.close();
});
});
describe('era-aware counter-offer ordering (the guard that precedes any constant bump)', () => {
it('an unknown requested version is countered with the latest LEGACY version even when the list carries a modern one', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS });
const response = await sendRaw(server, initializeRequest('1999-01-01'));
if (!isJSONRPCResultResponse(response)) throw new Error('expected result');
const result = InitializeResultSchema.parse(response.result);
// supportedProtocolVersions[0] is the modern revision here — the
// counter-offer must NOT be it: a fallback initialize never meets a
// leaked 2026 string at this site.
expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
expect(result.protocolVersion).not.toBe(MODERN);
await server.close();
});
it('an initialize REQUESTING the modern revision is also answered with the latest legacy version (initialize never negotiates a modern era)', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {}, supportedProtocolVersions: DUAL_ERA_VERSIONS });
const response = await sendRaw(server, initializeRequest(MODERN));
if (!isJSONRPCResultResponse(response)) throw new Error('expected result');
const result = InitializeResultSchema.parse(response.result);
expect(result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION);
await server.close();
});
it('default-list behavior is byte-identical: the legacy subset IS the whole list today', async () => {
const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} });
const response = await sendRaw(server, initializeRequest('1999-01-01'));
if (!isJSONRPCResultResponse(response)) throw new Error('expected result');
const result = InitializeResultSchema.parse(response.result);
expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]);
await server.close();
});
});

View file

@ -0,0 +1,248 @@
/**
* Era-parity error shapes: the same malformed input produces the same
* JSON-RPC error shape on the 2025-era (session-oriented streamable HTTP
* transport) and on the modern per-request path — modulo an explicitly
* enumerated table of era-mandated differences. Anything outside that table
* is a parity regression.
*/
import type { CallToolResult, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal';
import {
classifyInboundRequest,
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
ProtocolError,
setNegotiatedProtocolVersion
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
import { Server } from '../../src/server/server';
import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp';
const MODERN_REVISION = '2026-07-28';
const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION };
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'parity-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
/**
* Era-mandated differences between the two serving paths for the inputs
* exercised below. Everything else must be identical.
*
* - HTTP status: pre-handler rejections are status-mapped on the modern
* per-request path (e.g. method-not-found answers HTTP 404), while the
* 2025-era transport always carries dispatch errors in-band on HTTP 200.
* Asserted literally on both legs by the unknown-method test below.
* - The modern era requires the per-request `_meta` envelope on every
* request; the inputs below carry it on the modern leg only, where it is
* wire-level bookkeeping that never reaches handlers.
* - The malformed-body divergences enumerated in {@link KNOWN_EDGE_DIVERGENCES},
* asserted literally on both legs by the divergence-table test below.
*/
/**
* Known, deliberate divergences between what the deployed 2025-era streamable
* HTTP transport answers for a malformed POST body and what the modern edge
* (the inbound classifier) answers for the same body.
*
* These are hand-written literals — NOT derived from the observed behavior of
* either leg — so a behavior change on EITHER side fails the assertions below
* and forces this enumeration (and the matching cell-sheet rationales in the
* core package) to be revisited.
*/
const KNOWN_EDGE_DIVERGENCES: ReadonlyArray<{
divergence: string;
/** The parsed POST body both legs receive. */
body: unknown;
/** What the deployed 2025-era transport answers today. */
legacy: { httpStatus: number; code?: number };
/** What the modern edge (the inbound classifier) answers. */
modernEdge: { httpStatus: number; code: number };
rationale: string;
}> = [
{
divergence: 'parsed-but-not-json-rpc-single-object',
body: { hello: 'world' },
legacy: { httpStatus: 400, code: -32_700 },
modernEdge: { httpStatus: 400, code: -32_600 },
rationale:
'The deployed transport answers a parse error (-32700) for a parsed body that is not a JSON-RPC message; the modern ' +
'edge answers the JSON-RPC-correct invalid request (-32600).'
},
{
divergence: 'empty-batch',
body: [],
legacy: { httpStatus: 202 },
modernEdge: { httpStatus: 400, code: -32_600 },
rationale:
'The deployed transport accepts an empty batch as containing only notifications (202, no body); the modern edge ' +
'rejects it as an invalid request.'
}
];
interface LegError {
status: number;
error: { code: number; message: string; data?: unknown };
}
function buildServer(): Server {
const server = new Server({ name: 'parity', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/call', async (): Promise<CallToolResult> => ({ content: [{ type: 'text', text: 'ok' }] }));
server.setRequestHandler('app/fail', { params: z.looseObject({}) }, async () => {
throw new ProtocolError(-32_002, 'resource missing');
});
return server;
}
/**
* Posts an arbitrary (possibly malformed) body to the deployed 2025-era
* transport and returns the raw HTTP outcome — unlike {@link legacyLeg}, it
* does not assume the response carries a JSON error body (a 202 has none).
*/
async function legacyRawLeg(body: unknown): Promise<{ status: number; error?: LegError['error'] }> {
const server = buildServer();
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
await server.connect(transport);
const response = await transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body)
})
);
const text = await response.text();
await server.close();
return {
status: response.status,
...(text.length > 0 && { error: (JSON.parse(text) as { error: LegError['error'] }).error })
};
}
async function legacyLeg(body: Record<string, unknown>): Promise<LegError> {
const server = buildServer();
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
await server.connect(transport);
const response = await transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(body)
})
);
const parsed = (await response.json()) as { error: LegError['error'] };
await server.close();
return { status: response.status, error: parsed.error };
}
async function modernLeg(body: Record<string, unknown>): Promise<LegError> {
const server = buildServer();
setNegotiatedProtocolVersion(server, MODERN_REVISION);
const transport = new PerRequestHTTPServerTransport({ classification: MODERN });
await server.connect(transport);
const enveloped = {
...body,
params: { ...(body['params'] as Record<string, unknown> | undefined), _meta: ENVELOPE }
};
const response = await transport.handleMessage(enveloped as unknown as JSONRPCRequest);
const parsed = (await response.json()) as { error: LegError['error'] };
await server.close();
return { status: response.status, error: parsed.error };
}
describe('era-parity error shapes', () => {
it.each(KNOWN_EDGE_DIVERGENCES)(
'known divergence "$divergence": both legs answer exactly what the table enumerates',
async ({ body, legacy, modernEdge }) => {
// Legacy leg: the deployed 2025-era transport, exercised over HTTP.
const legacyActual = await legacyRawLeg(body);
expect(legacyActual.status).toBe(legacy.httpStatus);
if (legacy.code !== undefined) {
expect(legacyActual.error?.code).toBe(legacy.code);
} else {
expect(legacyActual.error).toBeUndefined();
}
// Modern leg: the per-request path answers these bodies at the
// edge (the inbound classifier) — they never reach a transport.
const modernActual = classifyInboundRequest({ httpMethod: 'POST', body });
expect(modernActual.kind).toBe('reject');
if (modernActual.kind !== 'reject') return;
expect(modernActual.httpStatus).toBe(modernEdge.httpStatus);
expect(modernActual.code).toBe(modernEdge.code);
}
);
it('an unknown method produces the same JSON-RPC error on both legs (status mapping is the enumerated difference)', async () => {
const input = { jsonrpc: '2.0', id: 11, method: 'definitely/unknown', params: {} };
const legacy = await legacyLeg(input);
const modern = await modernLeg(input);
expect(legacy.error.code).toBe(-32_601);
expect(modern.error.code).toBe(legacy.error.code);
expect(modern.error.message).toBe(legacy.error.message);
expect(modern.error.data).toEqual(legacy.error.data);
// Enumerated difference: http-status-mapping.
expect(legacy.status).toBe(200);
expect(modern.status).toBe(404);
});
it('a handler-thrown protocol error produces the same in-band JSON-RPC error on both legs', async () => {
const input = { jsonrpc: '2.0', id: 12, method: 'app/fail', params: {} };
const legacy = await legacyLeg(input);
const modern = await modernLeg(input);
expect(legacy.status).toBe(200);
expect(modern.status).toBe(200);
// The encode seam selects the wire code: a handler-thrown −32002 is
// emitted as −32602 on BOTH eras (no era branch preserves −32002).
expect(legacy.error).toMatchObject({ code: -32_602, message: 'resource missing' });
expect(modern.error).toEqual(legacy.error);
});
it('a handler-level invalid-params rejection produces the same in-band error code on both legs', async () => {
const failingParams = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} });
// Same registration on both legs: a custom method with a params schema
// the input does not satisfy.
const register = (server: Server) =>
server.setRequestHandler('app/strict', { params: z.object({ value: z.string() }) }, async params => ({ ok: params.value }));
register(failingParams);
const legacyTransport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
await failingParams.connect(legacyTransport);
const legacyResponse = await legacyTransport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 13, method: 'app/strict', params: { value: 7 } })
})
);
const legacyBody = (await legacyResponse.json()) as { error: { code: number } };
await failingParams.close();
const modernServer = new Server({ name: 'parity-params', version: '1.0.0' }, { capabilities: {} });
register(modernServer);
setNegotiatedProtocolVersion(modernServer, MODERN_REVISION);
const modernTransport = new PerRequestHTTPServerTransport({ classification: MODERN });
await modernServer.connect(modernTransport);
const modernResponse = await modernTransport.handleMessage({
jsonrpc: '2.0',
id: 13,
method: 'app/strict',
params: { value: 7, _meta: ENVELOPE }
} as JSONRPCRequest);
const modernBody = (await modernResponse.json()) as { error: { code: number } };
await modernServer.close();
expect(legacyBody.error.code).toBe(-32_602);
expect(modernBody.error.code).toBe(legacyBody.error.code);
// Handler-level invalid params stays in-band on both legs.
expect(legacyResponse.status).toBe(200);
expect(modernResponse.status).toBe(200);
});
});

View file

@ -0,0 +1,455 @@
/**
* Server-side multi-round-trip seam (M4.1):
*
* - a handler for tools/call, prompts/get, or resources/read returns an
* input-required result on a 2026-07-28-classified request and it reaches
* the wire as `resultType: 'input_required'` (validateToolOutput and the
* tools/call result schema are skipped for it; cache fields are never
* stamped on it);
* - the guards: at-least-one re-check for hand-built results, the per-embedded
* -request `-32021` capability check against the request's OWN envelope
* capabilities, the server-bug guard (non-multi-round-trip methods, and any
* method on a 2025-era request, never put a mis-typed result on the wire);
* - a UrlElicitationRequiredError escaping a handler on the modern era fails
* LOUDLY (clear steer to inputRequired.elicitUrl(...), never converted) —
* `-32042` never reaches the 2026-07-28 wire — while 2025-era traffic keeps
* today's `-32042` behavior;
* - the push-style APIs loud-fail on 2026-era requests with the
* `inputRequired(...)` steer surfaced through the tools/call catch-all, with
* zero wire traffic emitted for the attempted server→client request;
* - the write-once re-entry: a retried request's `inputResponses` reach the
* handler via ctx and the final result passes full validation.
*/
import type {
JSONRPCErrorResponse,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResultResponse
} from '@modelcontextprotocol/core-internal';
import {
acceptedContent,
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
InMemoryTransport,
inputRequired,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY,
setNegotiatedProtocolVersion,
UrlElicitationRequiredError
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/server/mcp';
import type { ServerOptions } from '../../src/server/server';
import { Server } from '../../src/server/server';
const MODERN = '2026-07-28';
const envelope = (clientCapabilities: Record<string, unknown> = {}) => ({
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_INFO_META_KEY]: { name: 'mrtr-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: clientCapabilities
});
async function wire(server: McpServer | Server, options?: { era?: 'modern' | 'legacy' }) {
const [peerTx, serverTx] = InMemoryTransport.createLinkedPair();
const inbound: JSONRPCMessage[] = [];
const waiters = new Map<string | number, (message: JSONRPCMessage) => void>();
peerTx.onmessage = message => {
inbound.push(message);
const id = (message as { id?: string | number }).id;
const waiter = id === undefined ? undefined : waiters.get(id);
if (id !== undefined && waiter) {
waiters.delete(id);
waiter(message);
}
};
await server.connect(serverTx);
await peerTx.start();
// Era is instance state: a serving entry binds the instance modern; for
// these unit tests we bind directly via the package-internal setter (the
// way createMcpHandler/serveStdio do).
if (options?.era === 'modern') {
setNegotiatedProtocolVersion(server instanceof Server ? server : server.server, MODERN);
}
const request = (message: JSONRPCRequest): Promise<JSONRPCMessage> =>
new Promise(resolve => {
waiters.set(message.id, resolve);
void peerTx.send(message);
});
const notify = (message: JSONRPCNotification): Promise<void> => peerTx.send(message);
return { request, notify, inbound, close: () => server.close() };
}
const modernToolCall = (
id: number,
name: string,
args: Record<string, unknown> = {},
options?: { clientCapabilities?: Record<string, unknown>; extraParams?: Record<string, unknown> }
): JSONRPCRequest => ({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: {
_meta: envelope(options?.clientCapabilities ?? {}),
name,
arguments: args,
...options?.extraParams
}
});
const legacyInitialize = (id: number): JSONRPCRequest => ({
jsonrpc: '2.0',
id,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } }
});
function resultOf(message: JSONRPCMessage): Record<string, unknown> {
return (message as JSONRPCResultResponse).result as unknown as Record<string, unknown>;
}
function errorOf(message: JSONRPCMessage): { code: number; message: string; data?: unknown } {
return (message as JSONRPCErrorResponse).error;
}
describe('input-required returns on the 2026-07-28 era', () => {
it('a write-once tool returning inputRequired() reaches the wire as input_required and completes on the retry', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool(
'deploy',
{ inputSchema: z.object({ env: z.string() }), outputSchema: z.object({ deployed: z.boolean() }) },
async ({ env }, ctx) => {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
if (!confirmed?.confirm) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Deploy to ${env}?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } } }
})
},
requestState: 'opaque-deploy-state'
});
}
return { content: [{ type: 'text', text: 'deployed' }], structuredContent: { deployed: true } };
}
);
const { request, close } = await wire(server, { era: 'modern' });
// First leg: input_required goes out, with no cache stamping and the
// structured-content requirement skipped.
const first = resultOf(
await request(modernToolCall(1, 'deploy', { env: 'prod' }, { clientCapabilities: { elicitation: { form: {} } } }))
);
expect(first.resultType).toBe('input_required');
expect(first.requestState).toBe('opaque-deploy-state');
expect(first.inputRequests).toMatchObject({ confirm: { method: 'elicitation/create' } });
expect(first.ttlMs).toBeUndefined();
expect(first.cacheScope).toBeUndefined();
expect(first.content).toBeUndefined();
// Retry leg (fresh id, responses + byte-exact echo): full validation
// applies to the completing result, which is stamped 'complete'.
const second = resultOf(
await request(
modernToolCall(
2,
'deploy',
{ env: 'prod' },
{
clientCapabilities: { elicitation: { form: {} } },
extraParams: {
inputResponses: { confirm: { action: 'accept', content: { confirm: true } } },
requestState: 'opaque-deploy-state'
}
}
)
)
);
expect(second.resultType).toBe('complete');
expect(second.structuredContent).toEqual({ deployed: true });
await close();
});
it('prompts/get and resources/read handlers can return input_required (no catch-all rewraps it)', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { prompts: {}, resources: {} } });
server.registerPrompt('wizard', { argsSchema: z.object({}) }, async () => inputRequired({ requestState: 'prompt-state' }));
server.registerResource('secret', 'file:///secret.txt', {}, async () => inputRequired({ requestState: 'resource-state' }));
const { request, close } = await wire(server, { era: 'modern' });
const promptResult = resultOf(
await request({
jsonrpc: '2.0',
id: 1,
method: 'prompts/get',
params: { _meta: envelope(), name: 'wizard', arguments: {} }
})
);
expect(promptResult.resultType).toBe('input_required');
expect(promptResult.requestState).toBe('prompt-state');
const resourceResult = resultOf(
await request({
jsonrpc: '2.0',
id: 2,
method: 'resources/read',
params: { _meta: envelope(), uri: 'file:///secret.txt' }
})
);
expect(resourceResult.resultType).toBe('input_required');
expect(resourceResult.requestState).toBe('resource-state');
expect(resourceResult.ttlMs).toBeUndefined();
await close();
});
});
describe('guards', () => {
it('hand-built results missing both inputRequests and requestState fail loudly (at-least-one re-check)', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('broken', { inputSchema: z.object({}) }, async () => ({ resultType: 'input_required' }) as never);
const { request, close } = await wire(server, { era: 'modern' });
const answer = await request(modernToolCall(1, 'broken'));
expect(errorOf(answer).code).toBe(-32_603);
expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"');
await close();
});
it('checks every embedded request against the capabilities the request itself declared (-32021 on violation)', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('ask', { inputSchema: z.object({}) }, async () =>
inputRequired({
inputRequests: {
confirm: inputRequired.elicit({ message: 'OK?', requestedSchema: { type: 'object', properties: {} } })
}
})
);
server.registerTool('open-url', { inputSchema: z.object({}) }, async () =>
inputRequired({
inputRequests: {
auth: inputRequired.elicitUrl({ message: 'Sign in', url: 'https://example.com' })
}
})
);
const { request, close } = await wire(server, { era: 'modern' });
// No elicitation capability declared on the request → -32021 naming
// the form sub-capability the embedded form-mode elicitation needs.
const noCapability = await request(modernToolCall(1, 'ask', {}, { clientCapabilities: {} }));
expect(errorOf(noCapability).code).toBe(-32_021);
expect(errorOf(noCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } });
// Form-mode capability declared → the same tool is served.
const withCapability = await request(modernToolCall(2, 'ask', {}, { clientCapabilities: { elicitation: { form: {} } } }));
expect(resultOf(withCapability).resultType).toBe('input_required');
// URL-mode embedded request requires elicitation.url specifically.
const urlWithoutUrlCapability = await request(
modernToolCall(3, 'open-url', {}, { clientCapabilities: { elicitation: { form: {} } } })
);
expect(errorOf(urlWithoutUrlCapability).code).toBe(-32_021);
expect(errorOf(urlWithoutUrlCapability).data).toMatchObject({ requiredCapabilities: { elicitation: { url: {} } } });
// Form-mode embedded request toward a URL-only client → -32021: modes
// are sub-capabilities and the server must not send an undeclared one.
const formTowardUrlOnly = await request(modernToolCall(4, 'ask', {}, { clientCapabilities: { elicitation: { url: {} } } }));
expect(errorOf(formTowardUrlOnly).code).toBe(-32_021);
expect(errorOf(formTowardUrlOnly).data).toMatchObject({ requiredCapabilities: { elicitation: { form: {} } } });
// A bare `elicitation: {}` declaration is read as form support (the
// pre-mode meaning of a bare declaration) → served.
const bareElicitation = await request(modernToolCall(5, 'ask', {}, { clientCapabilities: { elicitation: {} } }));
expect(resultOf(bareElicitation).resultType).toBe('input_required');
await close();
});
it('a 2025-era request never sees an input_required result: the server fails loudly instead (server-bug guard)', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('deploy', { inputSchema: z.object({}) }, async () => inputRequired({ requestState: 'state' }));
const { request, close } = await wire(server);
await request(legacyInitialize(1));
const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'deploy', arguments: {} } });
expect(errorOf(answer).code).toBe(-32_603);
// The mis-typed result never reaches the wire: the answer is an error, not a result.
expect((answer as { result?: unknown }).result).toBeUndefined();
await close();
});
it('non-multi-round-trip methods can never emit input_required (server-bug guard)', async () => {
const server = new Server({ name: 's', version: '1.0.0' }, { capabilities: { completions: {} } });
server.setRequestHandler('completion/complete', async () => ({ resultType: 'input_required', requestState: 's' }) as never);
const { request, close } = await wire(server, { era: 'modern' });
const answer = await request({
jsonrpc: '2.0',
id: 1,
method: 'completion/complete',
params: {
_meta: envelope(),
ref: { type: 'ref/prompt', name: 'p' },
argument: { name: 'a', value: 'v' }
}
});
expect(errorOf(answer).code).toBe(-32_603);
// The mis-typed result never reaches the wire: the answer is an error, not a result.
expect((answer as { result?: unknown }).result).toBeUndefined();
await close();
});
});
describe('UrlElicitationRequiredError (the 2025-era -32042 idiom)', () => {
const URL_PARAMS = { mode: 'url' as const, message: 'Sign in to continue', elicitationId: 'elicit-7', url: 'https://example.com/auth' };
function buildUrlThrowingServer() {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('protected', { inputSchema: z.object({}) }, async () => {
throw new UrlElicitationRequiredError([URL_PARAMS]);
});
return server;
}
it('fails LOUDLY on a 2026-era request with a clear inputRequired.elicitUrl(...) steer — never converted, never -32042', async () => {
const { request, close } = await wire(buildUrlThrowingServer(), { era: 'modern' });
const answer = await request(modernToolCall(1, 'protected', {}, { clientCapabilities: { elicitation: { url: {} } } }));
expect(errorOf(answer).code).toBe(-32_603);
expect(errorOf(answer).message).toContain('inputRequired.elicitUrl');
expect(JSON.stringify(answer)).not.toContain('"resultType":"input_required"');
// The -32042 error code never appears on the 2026-07-28 wire (the steer
// text mentions it for migration; the wire error code is InternalError).
expect(JSON.stringify(answer)).not.toContain('"code":-32042');
await close();
});
it('keeps the exact -32042 behavior for 2025-era traffic', async () => {
const { request, close } = await wire(buildUrlThrowingServer());
await request(legacyInitialize(1));
const answer = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'protected', arguments: {} } });
const error = errorOf(answer);
expect(error.code).toBe(-32_042);
expect(error.data).toEqual({ elicitations: [URL_PARAMS] });
await close();
});
});
describe('requestState.verify hook', () => {
function buildServer(options?: ServerOptions) {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} }, ...options });
const handler = vi.fn(async () => ({ content: [{ type: 'text' as const, text: 'ok' }] }));
server.registerTool('deploy', { inputSchema: z.object({}) }, handler);
return { server, handler };
}
const reentry = (id: number, requestState?: string) =>
modernToolCall(id, 'deploy', {}, { extraParams: requestState === undefined ? {} : { requestState } });
it('is called with the echoed state and the handler context, before the handler', async () => {
const seen: Array<{ state: string; method: string }> = [];
const { server, handler } = buildServer({
requestState: { verify: (state, ctx) => void seen.push({ state, method: ctx.mcpReq.method }) }
});
const { request, close } = await wire(server, { era: 'modern' });
const answer = resultOf(await request(reentry(1, 'signed-state')));
expect(seen).toEqual([{ state: 'signed-state', method: 'tools/call' }]);
expect(handler).toHaveBeenCalledOnce();
expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]);
await close();
});
it('a throw becomes the frozen -32602 wire error (not an isError tool result); the reason goes to onerror only', async () => {
const { server, handler } = buildServer({
requestState: {
verify: () => {
throw new Error('HMAC mismatch — granular reason');
}
}
});
const onerror = vi.fn();
server.server.onerror = onerror;
const { request, close } = await wire(server, { era: 'modern' });
const answer = await request(reentry(1, 'tampered'));
// Real JSON-RPC error (above the tools/call funnel), not a result.
expect((answer as { result?: unknown }).result).toBeUndefined();
const error = errorOf(answer);
expect(error.code).toBe(-32_602);
expect(error.message).toBe('Invalid or expired requestState');
expect(error.data).toEqual({ reason: 'invalid_request_state' });
// The granular reason never reaches the wire — onerror only.
expect(JSON.stringify(answer)).not.toContain('HMAC mismatch');
expect(onerror).toHaveBeenCalledOnce();
expect(String(onerror.mock.calls[0]?.[0])).toContain('HMAC mismatch');
expect(handler).not.toHaveBeenCalled();
await close();
});
it('is not called when the request carries no requestState', async () => {
const verify = vi.fn();
const { server, handler } = buildServer({ requestState: { verify } });
const { request, close } = await wire(server, { era: 'modern' });
const answer = resultOf(await request(reentry(1)));
expect(verify).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledOnce();
expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]);
await close();
});
it('not configured → today’s behavior (raw passthrough; the handler reads the state itself)', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
let seen: string | undefined;
server.registerTool('deploy', { inputSchema: z.object({}) }, async (_args, ctx) => {
seen = ctx.mcpReq.requestState;
return { content: [{ type: 'text', text: 'ok' }] };
});
const { request, close } = await wire(server, { era: 'modern' });
const answer = resultOf(await request(reentry(1, 'raw-state')));
expect(seen).toBe('raw-state');
expect(answer.content).toEqual([{ type: 'text', text: 'ok' }]);
await close();
});
});
describe('push-style APIs on 2026-era requests', () => {
it('ctx.mcpReq.elicitInput rejects before any wire traffic and the catch-all surfaces the inputRequired() steer as isError', async () => {
const server = new McpServer({ name: 's', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => {
const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } });
return { content: [{ type: 'text', text: JSON.stringify(answer) }] };
});
const { request, inbound, close } = await wire(server, { era: 'modern' });
const answer = await request(modernToolCall(1, 'legacy-style', {}, { clientCapabilities: { elicitation: { form: {} } } }));
const result = resultOf(answer);
expect(result.isError).toBe(true);
const text = JSON.stringify(result.content);
expect(text).toContain('inputRequired(');
// Zero wire traffic for the attempted server→client request: the only
// message the peer ever received is the tools/call response itself.
expect(inbound.filter(message => (message as { method?: string }).method === 'elicitation/create')).toHaveLength(0);
expect(inbound).toHaveLength(1);
await close();
});
});

View file

@ -0,0 +1,139 @@
/**
* The internal per-request invoke seam: one classified message in, one HTTP
* response out — value-returning and independently testable, with no HTTP
* server and no changes to protocol dispatch.
*
* The tests mark factory instances as modern-era through the package-internal
* negotiated-version hook, standing in for the HTTP entry that will own that
* write in production.
*/
import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
setNegotiatedProtocolVersion
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { invoke } from '../../src/server/invoke';
import { McpServer } from '../../src/server/mcp';
import { Server } from '../../src/server/server';
const MODERN_REVISION = '2026-07-28';
const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION };
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'invoke-seam-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
const toolsCall = (name: string, args: Record<string, unknown>): JSONRPCRequest =>
({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args, _meta: ENVELOPE }
}) as JSONRPCRequest;
function modernMcpServer(): McpServer {
const mcpServer = new McpServer({ name: 'invoke-seam-test', version: '1.0.0' });
mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({
content: [{ type: 'text', text: `hello ${who}` }]
}));
// Stand-in for the HTTP entry, which marks factory instances as modern-era
// at binding time through the same package-internal hook.
setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION);
return mcpServer;
}
describe('invoke', () => {
it('serves a classified request on a high-level server instance and returns the response value', async () => {
const response = await invoke(modernMcpServer(), toolsCall('greet', { who: 'world' }), { classification: MODERN });
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('hello world');
});
it('serves a classified request on a low-level server instance', async () => {
const server = new Server({ name: 'low-level', version: '1.0.0' }, { capabilities: {} });
server.setRequestHandler('app/sum', { params: z.looseObject({ a: z.number(), b: z.number() }) }, async params => ({
sum: params.a + params.b
}));
setNegotiatedProtocolVersion(server, MODERN_REVISION);
const response = await invoke(
server,
{ jsonrpc: '2.0', id: 7, method: 'app/sum', params: { a: 2, b: 3, _meta: ENVELOPE } } as JSONRPCRequest,
{ classification: MODERN }
);
expect(response.status).toBe(200);
const body = (await response.json()) as { id: number; result: { sum: number } };
expect(body.id).toBe(7);
expect(body.result.sum).toBe(5);
});
it('answers an era-removed method with method-not-found and HTTP 404', async () => {
const response = await invoke(
modernMcpServer(),
{ jsonrpc: '2.0', id: 2, method: 'ping', params: { _meta: ENVELOPE } } as JSONRPCRequest,
{ classification: MODERN }
);
expect(response.status).toBe(404);
const body = (await response.json()) as { error: { code: number } };
expect(body.error.code).toBe(-32_601);
});
it('acknowledges classified notifications with 202 and no body', async () => {
const response = await invoke(
modernMcpServer(),
{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 99 } } as JSONRPCNotification,
{ classification: MODERN }
);
expect(response.status).toBe(202);
expect(await response.text()).toBe('');
});
it('protects unmarked instances: modern-classified traffic gets the protocol-version error', async () => {
const mcpServer = new McpServer({ name: 'unmarked', version: '1.0.0' });
mcpServer.registerTool('greet', { inputSchema: z.object({ who: z.string() }) }, async ({ who }) => ({
content: [{ type: 'text', text: `hello ${who}` }]
}));
mcpServer.server.onerror = () => {
// the era mismatch is also surfaced out of band; irrelevant here
};
const response = await invoke(mcpServer, toolsCall('greet', { who: 'world' }), { classification: MODERN });
expect(response.status).toBe(400);
const body = (await response.json()) as { error: { code: number; data: { supported: string[] } } };
expect(body.error.code).toBe(-32_022);
expect(Array.isArray(body.error.data.supported)).toBe(true);
});
it('passes the original request and caller-supplied auth info through to handler context', async () => {
const mcpServer = new McpServer({ name: 'ctx-check', version: '1.0.0' });
let seenAuthClientId: string | undefined;
let seenAuthorizationHeader: string | null | undefined;
mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => {
seenAuthClientId = ctx.http?.authInfo?.clientId;
seenAuthorizationHeader = ctx.http?.req?.headers.get('authorization');
return { content: [{ type: 'text', text: 'ok' }] };
});
setNegotiatedProtocolVersion(mcpServer.server, MODERN_REVISION);
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: { authorization: 'Bearer raw-header-token' }
});
const response = await invoke(mcpServer, toolsCall('whoami', {}), {
classification: MODERN,
request,
authInfo: { token: 'verified-token', clientId: 'client-42', scopes: ['mcp'] }
});
expect(response.status).toBe(200);
// Caller-supplied auth info arrives as-is; the raw header stays a raw
// header and is never promoted to auth info by the seam.
expect(seenAuthClientId).toBe('client-42');
expect(seenAuthorizationHeader).toBe('Bearer raw-header-token');
});
});

View file

@ -0,0 +1,118 @@
/**
* Q10-L2 golden pin: a hand-constructed `McpServer` connected to a long-lived
* transport (the shape of every existing stdio server) serves a scripted 2025
* session with today's exact result shapes and zero 2026 vocabulary on the
* wire — and keeps answering `server/discover` with `-32601`, byte-identical
* to the deployed fleet. Hand-constructed instances serve only the 2025 era;
* serving the 2026-07-28 revision on stdio goes through the `serveStdio`
* entry (covered in `serveStdio.test.ts`).
*/
import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import {
InMemoryTransport,
isJSONRPCErrorResponse,
isJSONRPCResultResponse,
LATEST_PROTOCOL_VERSION
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { McpServer } from '../../src/server/mcp';
function buildServer() {
const server = new McpServer(
{ name: 'legacy-default-test-server', version: '1.0.0' },
{ capabilities: { tools: {} }, instructions: 'test instructions' }
);
server.registerTool('echo', { description: 'Echoes the input text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text }]
}));
return server;
}
async function wire(server: McpServer) {
const [peerTx, serverTx] = InMemoryTransport.createLinkedPair();
const inbound: JSONRPCMessage[] = [];
const waiters = new Map<string | number, (message: JSONRPCMessage) => void>();
peerTx.onmessage = message => {
inbound.push(message);
const id = (message as { id?: string | number }).id;
const waiter = id === undefined ? undefined : waiters.get(id);
if (id !== undefined && waiter) {
waiters.delete(id);
waiter(message);
}
};
await server.connect(serverTx);
await peerTx.start();
const request = (message: JSONRPCRequest): Promise<JSONRPCMessage> =>
new Promise(resolve => {
waiters.set(message.id, resolve);
void peerTx.send(message);
});
const notify = (message: JSONRPCNotification): Promise<void> => peerTx.send(message);
return { request, notify, inbound, close: () => server.close() };
}
const initializeRequest = (id: number): JSONRPCRequest => ({
jsonrpc: '2.0',
id,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } }
});
describe('Q10-L2: a hand-constructed server on 2025 traffic', () => {
it('serves a scripted 2025 session with the exact 2025 shapes and zero 2026 vocabulary on the wire', async () => {
const server = buildServer();
const { request, notify, inbound, close } = await wire(server);
const init = await request(initializeRequest(1));
expect(isJSONRPCResultResponse(init)).toBe(true);
if (isJSONRPCResultResponse(init)) {
expect(init.result).toEqual({
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'legacy-default-test-server', version: '1.0.0' },
instructions: 'test instructions'
});
}
await notify({ jsonrpc: '2.0', method: 'notifications/initialized' });
const list = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
expect(isJSONRPCResultResponse(list)).toBe(true);
if (isJSONRPCResultResponse(list)) {
const tools = (list.result as { tools: Array<Record<string, unknown>> }).tools;
expect(tools).toHaveLength(1);
expect(tools[0]).toMatchObject({ name: 'echo', description: 'Echoes the input text' });
expect(Object.keys(list.result as Record<string, unknown>).sort()).toEqual(['tools']);
}
const call = await request({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'echo', arguments: { text: 'hi' } } });
expect(isJSONRPCResultResponse(call)).toBe(true);
if (isJSONRPCResultResponse(call)) {
expect(call.result).toEqual({ content: [{ type: 'text', text: 'hi' }] });
}
const ping = await request({ jsonrpc: '2.0', id: 4, method: 'ping' });
expect(isJSONRPCResultResponse(ping)).toBe(true);
if (isJSONRPCResultResponse(ping)) {
expect(ping.result).toEqual({});
}
// A default instance keeps answering server/discover with -32601, byte-identical to the deployed fleet.
const discover = await request({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} });
expect(isJSONRPCErrorResponse(discover)).toBe(true);
if (isJSONRPCErrorResponse(discover)) {
expect(discover.error).toEqual({ code: -32_601, message: 'Method not found' });
}
// Nothing the server wrote on this 2025 session carries 2026 wire vocabulary.
const wireBytes = JSON.stringify(inbound);
expect(wireBytes).not.toContain('resultType');
expect(wireBytes).not.toContain('2026');
expect(wireBytes).not.toContain('io.modelcontextprotocol/');
await close();
});
});

View file

@ -0,0 +1,184 @@
/**
* legacyStatelessFallback — the entry's default legacy serving, tested
* independently of createMcpHandler: per-request stateless serving via the
* frozen idiom (fresh instance + sessionIdGenerator: undefined + handleRequest).
*/
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import type { McpRequestContext } from '../../src/server/createMcpHandler';
import { legacyStatelessFallback } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
interface JSONRPCErrorBody {
jsonrpc: string;
id: unknown;
error: { code: number; message: string };
}
function postRequest(body: unknown): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify(body)
});
}
describe('legacyStatelessFallback', () => {
it('serves each POST on a fresh instance from the factory (stateless idiom)', async () => {
const contexts: McpRequestContext[] = [];
const products: McpServer[] = [];
const handler = legacyStatelessFallback(ctx => {
contexts.push(ctx);
const mcpServer = new McpServer({ name: 'fallback-test', version: '1.0.0' });
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
products.push(mcpServer);
return mcpServer;
});
const first = await handler(
postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'one' } } })
);
expect(first.status).toBe(200);
expect(await first.text()).toContain('one');
const second = await handler(
postRequest({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'echo', arguments: { text: 'two' } } })
);
expect(second.status).toBe(200);
expect(await second.text()).toContain('two');
expect(products).toHaveLength(2);
expect(products[0]).not.toBe(products[1]);
expect(contexts.every(ctx => ctx.era === 'legacy')).toBe(true);
});
it('passes caller-provided authInfo and parsedBody through to the legacy transport', async () => {
let seenClientId: string | undefined;
const handler = legacyStatelessFallback(() => {
const mcpServer = new McpServer({ name: 'fallback-auth', version: '1.0.0' });
mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx) => {
seenClientId = ctx.http?.authInfo?.clientId;
return { content: [{ type: 'text', text: 'ok' }] };
});
return mcpServer;
});
const body = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'whoami', arguments: {} } };
const response = await handler(postRequest(body), {
authInfo: { token: 'verified', clientId: 'fallback-client', scopes: [] },
parsedBody: body
});
expect(response.status).toBe(200);
// Drain the exchange before asserting: the tool handler runs while the
// per-request stream is open.
expect(await response.text()).toContain('ok');
expect(seenClientId).toBe('fallback-client');
});
it('answers GET and DELETE with 405 / Method not allowed. like the canonical stateless example', async () => {
const handler = legacyStatelessFallback(() => new McpServer({ name: 'fallback-405', version: '1.0.0' }));
for (const method of ['GET', 'DELETE']) {
const response = await handler(new Request('http://localhost/mcp', { method }));
expect(response.status).toBe(405);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toBe('Method not allowed.');
expect(body.id).toBeNull();
}
});
it('tears the per-request pair down after a normally-completed SSE exchange (factory product close hooks fire)', async () => {
let productClosed = false;
const handler = legacyStatelessFallback(() => {
const mcpServer = new McpServer({ name: 'fallback-teardown', version: '1.0.0' });
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
mcpServer.server.onclose = () => {
productClosed = true;
};
return mcpServer;
});
const response = await handler(
postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'all done' } } })
);
expect(response.status).toBe(200);
// Request-bearing POSTs are answered over SSE by the stateless idiom's
// default transport options — the dominant legacy exchange shape.
expect(response.headers.get('content-type')).toContain('text/event-stream');
expect(productClosed).toBe(false);
// Drain the stream to completion: only then is the exchange over.
expect(await response.text()).toContain('all done');
await vi.waitFor(() => {
expect(productClosed).toBe(true);
});
});
it('still tears the per-request pair down when the client aborts a streaming exchange', async () => {
let productClosed = false;
const handler = legacyStatelessFallback(ctx => {
const mcpServer = new McpServer({ name: 'fallback-abort', version: '1.0.0' });
mcpServer.registerTool('park', { inputSchema: z.object({}) }, async (_args, toolCtx) => {
await new Promise<void>(resolve => {
toolCtx.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true });
});
return { content: [{ type: 'text', text: `parked on ${ctx.era}` }] };
});
mcpServer.server.onclose = () => {
productClosed = true;
};
return mcpServer;
});
const controller = new AbortController();
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'park', arguments: {} } }),
signal: controller.signal
});
const response = await handler(request);
expect(response.status).toBe(200);
expect(productClosed).toBe(false);
controller.abort();
await vi.waitFor(() => {
expect(productClosed).toBe(true);
});
});
it('answers factory failures with a 500 internal error body', async () => {
const handler = legacyStatelessFallback(() => {
throw new Error('factory exploded');
});
const response = await handler(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }));
expect(response.status).toBe(500);
const body = (await response.json()) as JSONRPCErrorBody;
expect(body.error.code).toBe(-32_603);
});
it('reports failures through the optional onerror callback while keeping the 500 response', async () => {
const onerror = vi.fn();
const handler = legacyStatelessFallback(() => {
throw new Error('factory exploded');
}, onerror);
const response = await handler(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }));
expect(response.status).toBe(500);
expect(((await response.json()) as JSONRPCErrorBody).error.code).toBe(-32_603);
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' }));
});
});

View file

@ -0,0 +1,51 @@
/**
* SF-02 — deterministic list ordering across requests.
*
* The spec recommends servers return tools/prompts/resources in a stable,
* deterministic order across requests when the underlying set has not changed.
* `McpServer` registries are plain string-keyed objects, so iteration is
* insertion order; this test pins that ordering at the wire so a registry
* refactor cannot quietly change it.
*/
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { invoke } from '../../src/server/invoke';
import { McpServer } from '../../src/server/mcp';
const LEGACY = { classification: { era: 'legacy' as const } };
const list = async (server: McpServer, method: string, key: string): Promise<string[]> => {
const response = await invoke(server, { jsonrpc: '2.0', id: 1, method, params: {} }, LEGACY);
const body = (await response.json()) as { result: Record<string, Array<{ name: string }>> };
return body.result[key]!.map(item => item.name);
};
describe('McpServer list ordering', () => {
it('tools/list, prompts/list and resources/list each return in registration order, stable across calls', async () => {
const server = new McpServer({ name: 'ordering', version: '0' });
// Non-sorted registration order so neither alphabetic nor reverse would mask it.
const toolOrder = ['gamma', 'alpha', 'mu', 'beta'];
for (const name of toolOrder) {
server.registerTool(name, { inputSchema: z.object({}) }, async () => ({ content: [] }));
}
const promptOrder = ['second', 'first', 'third'];
for (const name of promptOrder) {
server.registerPrompt(name, {}, async () => ({ messages: [] }));
}
const resourceOrder = ['c', 'a', 'b'];
for (const name of resourceOrder) {
server.registerResource(name, `mem://${name}`, {}, async () => ({ contents: [] }));
}
expect(await list(server, 'tools/list', 'tools')).toEqual(toolOrder);
expect(await list(server, 'tools/list', 'tools')).toEqual(toolOrder);
expect(await list(server, 'prompts/list', 'prompts')).toEqual(promptOrder);
expect(await list(server, 'prompts/list', 'prompts')).toEqual(promptOrder);
expect(await list(server, 'resources/list', 'resources')).toEqual(resourceOrder);
expect(await list(server, 'resources/list', 'resources')).toEqual(resourceOrder);
});
});

View file

@ -0,0 +1,114 @@
/**
* SEP-2106 legacy projection lives in the wire codec — proven on a low-level
* `Server` (NOT `McpServer`). The handler is era-blind and returns a
* non-object `outputSchema` / `structuredContent` directly; on a 2025-era
* connection the WIRE BYTES carry the wrapped `{type:'object',
* properties:{result:…}}` schema and the wrapped `{result:<value>}`
* structured content. Nothing in `mcp.ts` (or any server-side code) re-derives
* the wrap — the only era branch is the codec.
*/
import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Server } from '../../src/server/server';
async function wire(server: Server) {
const [peerTx, serverTx] = InMemoryTransport.createLinkedPair();
const waiters = new Map<string | number, (message: JSONRPCMessage) => void>();
peerTx.onmessage = message => {
const id = (message as { id?: string | number }).id;
const waiter = id === undefined ? undefined : waiters.get(id);
if (id !== undefined && waiter) {
waiters.delete(id);
waiter(message);
}
};
await server.connect(serverTx);
await peerTx.start();
const request = (message: JSONRPCRequest): Promise<JSONRPCMessage> =>
new Promise(resolve => {
waiters.set(message.id, resolve);
void peerTx.send(message);
});
const notify = (message: JSONRPCNotification): Promise<void> => peerTx.send(message);
return { request, notify };
}
const initializeRequest = (id: number): JSONRPCRequest => ({
jsonrpc: '2.0',
id,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'legacy-client', version: '1.0.0' } }
});
describe('SEP-2106: the 2025 wire codec owns the legacy {result:…} wrap (low-level Server)', () => {
it("encodeResult('tools/list') wraps a non-object outputSchema root on the wire", async () => {
const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } });
// Era-blind handler: returns the natural (2026-vocabulary) array-rooted outputSchema.
server.setRequestHandler('tools/list', () => ({
tools: [{ name: 'x', inputSchema: { type: 'object' as const }, outputSchema: { type: 'array', items: { type: 'number' } } }]
}));
const { request, notify } = await wire(server);
await request(initializeRequest(1));
await notify({ jsonrpc: '2.0', method: 'notifications/initialized' });
const reply = await request({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
if (!isJSONRPCResultResponse(reply)) throw new Error(`expected result, got ${JSON.stringify(reply)}`);
const tools = (reply.result as { tools: ReadonlyArray<{ name: string; outputSchema?: unknown }> }).tools;
// Wire bytes carry the 2025-era projection — wrapped, not the natural schema.
expect(tools[0]?.outputSchema).toEqual({
type: 'object',
properties: { result: { type: 'array', items: { type: 'number' } } },
required: ['result']
});
});
it('projectCallToolResult wraps structuredContent as {result:…} when the advertised schema has a non-object root', async () => {
const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } });
const advertised = { type: 'array', items: { type: 'number' } } as const;
server.setRequestHandler('tools/list', () => ({
tools: [{ name: 'x', inputSchema: { type: 'object' as const }, outputSchema: advertised }]
}));
// Low-level handlers route the result-side projection through the codec themselves
// (McpServer's tools/call handler does the same call). The codec is the ONLY place
// the era branch lives.
server.setRequestHandler('tools/call', () =>
server.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, advertised)
);
const { request, notify } = await wire(server);
await request(initializeRequest(1));
await notify({ jsonrpc: '2.0', method: 'notifications/initialized' });
const reply = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'x', arguments: {} } });
if (!isJSONRPCResultResponse(reply)) throw new Error(`expected result, got ${JSON.stringify(reply)}`);
const result = reply.result as { structuredContent?: unknown; content?: ReadonlyArray<{ type: string; text?: string }> };
expect(result.structuredContent).toEqual({ result: [1, 2, 3] });
// The era-agnostic SEP-2106 §4.3 TextContent auto-append also lives behind the codec.
expect(result.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) });
});
it('projectCallToolResult wraps a non-object structuredContent value as {result:…} REGARDLESS of advertised schema (schema-less tool)', async () => {
// A schema-less tool (no `outputSchema` advertised) returning a non-object
// `structuredContent` would otherwise ship wire-illegal bytes on the 2025
// era — the wire shape requires `structuredContent` to be an object. The
// projection wraps on value shape alone, so the result is always
// wire-legal even when there is no schema to consult.
const server = new Server({ name: 's', version: '1' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/list', () => ({
tools: [{ name: 'x', inputSchema: { type: 'object' as const } }]
}));
server.setRequestHandler('tools/call', () =>
server.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, undefined)
);
const { request, notify } = await wire(server);
await request(initializeRequest(1));
await notify({ jsonrpc: '2.0', method: 'notifications/initialized' });
const reply = await request({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'x', arguments: {} } });
if (!isJSONRPCResultResponse(reply)) throw new Error(`expected result, got ${JSON.stringify(reply)}`);
const result = reply.result as { structuredContent?: unknown; content?: ReadonlyArray<{ type: string; text?: string }> };
expect(result.structuredContent).toEqual({ result: [1, 2, 3] });
expect(result.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) });
});
});

View file

@ -127,3 +127,23 @@ describe('InferRawShape', () => {
expectTypeOf<S>().toEqualTypeOf<{ a: string; b?: string | undefined }>();
});
});
describe('SEP-2106: registerTool with non-object outputSchema (type-level)', () => {
it('accepts z.array(z.number()) as outputSchema and a number[] structuredContent compiles', () => {
const server = new McpServer({ name: 's', version: '1' });
server.registerTool('arr', { inputSchema: z.object({ n: z.number() }), outputSchema: z.array(z.number()) }, async ({ n }) => ({
content: [],
structuredContent: [n, n + 1] satisfies number[]
}));
// NOTE (SEP-2106 PR-B verification item): the OutputArgs generic on registerTool is
// captured but does NOT currently flow into the callback's return type — ToolCallback's
// SendResultT is `CallToolResult | InputRequiredResult` (structuredContent: unknown), so
// a wrong-typed structuredContent ALSO compiles. Runtime validation (validateToolOutput)
// is the guard. Tightening the generic is out of this commit's scope.
server.registerTool('arr-loose', { outputSchema: z.array(z.number()) }, async () => ({
content: [],
structuredContent: 'not-an-array' // compiles: structuredContent is `unknown`
}));
expectTypeOf<number[]>().toMatchTypeOf<z.infer<ReturnType<typeof z.array<z.ZodNumber>>>>();
});
});

View file

@ -0,0 +1,137 @@
/**
* SEP-2243 server-side `Mcp-Param-*` validation at the createMcpHandler entry
* (protocol revision 2026-07-28).
*
* Pre-dispatch ladder rung: a `tools/call` whose `Mcp-Param-{Name}` headers
* disagree with the body `arguments` (or are missing for a present body value,
* or carry an invalid Base64 sentinel) is rejected `400` / `-32020` with the
* same `HeaderMismatch` shape the inbound classifier emits for the
* standard-header cross-checks. A `null`/absent body value passes regardless
* of the header (the spec's "server MUST NOT expect" rows). The
* registration-time declaration-validity check warns on invalid declarations.
*/
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
encodeMcpParamValue,
PROTOCOL_VERSION_META_KEY
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { fromJsonSchema } from '../../src/fromJsonSchema';
import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
const MODERN = '2026-07-28';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_INFO_META_KEY]: { name: 'param-test', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
const REGION_INPUT_SCHEMA = {
type: 'object',
properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } }
} as const;
function makeFactory(): () => McpServer {
return () => {
const s = new McpServer({ name: 'param-server', version: '1.0.0' });
s.registerTool('route', { inputSchema: fromJsonSchema<{ region?: string; query?: string }>(REGION_INPUT_SCHEMA) }, async args => ({
content: [{ type: 'text', text: `routed ${args.region ?? '<none>'}` }]
}));
return s;
};
}
function call(args: Record<string, unknown>, paramHeaders: Record<string, string> = {}): Request {
return new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-protocol-version': MODERN,
'mcp-method': 'tools/call',
'mcp-name': 'route',
...paramHeaders
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 7,
method: 'tools/call',
params: { name: 'route', arguments: args, _meta: ENVELOPE }
})
});
}
describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)', () => {
it('a matching Mcp-Param header passes and the call dispatches', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: 'us-west1', query: 'x' }, { 'Mcp-Param-Region': 'us-west1' }));
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('routed us-west1');
});
it('a Base64-sentinel header decodes and matches', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: 'Hello, 世界' }, { 'Mcp-Param-Region': encodeMcpParamValue('Hello, 世界') }));
expect(response.status).toBe(200);
});
it('a disagreeing header is rejected 400/-32020 (HeaderMismatch) and reports the rejection', async () => {
const onerror = vi.fn();
const handler = createMcpHandler(makeFactory(), { onerror });
const response = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' }));
expect(response.status).toBe(400);
const body = (await response.json()) as { id: unknown; error: { code: number; data?: { mismatch?: { header?: string } } } };
expect(body.error.code).toBe(-32_020);
expect(body.error.data?.mismatch?.header).toBe('Mcp-Param-Region');
expect(body.id).toBe(7);
expect(onerror).toHaveBeenCalled();
});
// sep-2243-server-reject-missing-required (globally-untested manifest check).
it('a missing header for a present body value is rejected 400/-32020', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: 'us-west1' }));
expect(response.status).toBe(400);
const body = (await response.json()) as { error: { code: number } };
expect(body.error.code).toBe(-32_020);
});
// sep-2243-server-not-expect-null (globally-untested manifest check).
it('a null/absent body value passes regardless of any stray header', async () => {
const handler = createMcpHandler(makeFactory());
const r1 = await handler.fetch(call({ query: 'x' }, { 'Mcp-Param-Region': 'whatever' }));
const r2 = await handler.fetch(call({ region: null as unknown as string, query: 'x' }));
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
});
it('an invalid Base64 sentinel is rejected 400/-32020', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: 'Hello' }, { 'Mcp-Param-Region': '=?base64?SGVsbG8?=' }));
expect(response.status).toBe(400);
expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020);
});
});
describe('SEP-2243 registerTool declaration-validity check', () => {
it('warns on an invalid x-mcp-header declaration at registration time', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const s = new McpServer({ name: 'warn-server', version: '1.0.0' });
s.registerTool(
'bad',
{
inputSchema: fromJsonSchema({
type: 'object',
properties: { a: { type: 'object', 'x-mcp-header': 'Data' } as Record<string, unknown> }
})
},
async () => ({ content: [] })
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool 'bad' carries an invalid x-mcp-header"));
warn.mockRestore();
});
});

View file

@ -0,0 +1,67 @@
/**
* Framework-agnostic Origin validation helpers: allowlist matching, the
* absent-header pass, and the deny-on-failure behavior for malformed values.
*/
import { describe, expect, it } from 'vitest';
import { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from '../../src/server/middleware/originValidation';
describe('validateOriginHeader', () => {
it('passes when no Origin header is present (non-browser clients)', () => {
expect(validateOriginHeader(undefined, ['localhost']).ok).toBe(true);
expect(validateOriginHeader(null, ['localhost']).ok).toBe(true);
expect(validateOriginHeader('', ['localhost']).ok).toBe(true);
});
it('allows origins whose hostname is on the allowlist, port- and scheme-agnostic', () => {
expect(validateOriginHeader('http://localhost:3000', ['localhost']).ok).toBe(true);
expect(validateOriginHeader('https://localhost', ['localhost']).ok).toBe(true);
expect(validateOriginHeader('http://127.0.0.1:8080', localhostAllowedOrigins()).ok).toBe(true);
expect(validateOriginHeader('http://[::1]:8080', localhostAllowedOrigins()).ok).toBe(true);
});
it('rejects origins whose hostname is not on the allowlist', () => {
const result = validateOriginHeader('http://evil.example.com', localhostAllowedOrigins());
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe('invalid_origin');
expect(result.message).toContain('evil.example.com');
}
});
it('rejects lookalike subdomains of allowed hostnames', () => {
expect(validateOriginHeader('http://localhost.evil.example.com', localhostAllowedOrigins()).ok).toBe(false);
});
it('denies on failure: unparseable Origin values and the opaque null origin are rejected, never passed through', () => {
for (const malformed of ['null', 'not a url', 'evil.example.com', 'about:blank']) {
const result = validateOriginHeader(malformed, localhostAllowedOrigins());
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe('invalid_origin_header');
}
}
});
});
describe('originValidationResponse', () => {
it('returns undefined for allowed and absent origins', () => {
const allowed = new Request('http://localhost/mcp', { headers: { origin: 'http://localhost:3000' } });
expect(originValidationResponse(allowed, localhostAllowedOrigins())).toBeUndefined();
const absent = new Request('http://localhost/mcp');
expect(originValidationResponse(absent, localhostAllowedOrigins())).toBeUndefined();
});
it('returns a 403 JSON-RPC error response for disallowed origins', async () => {
const request = new Request('http://localhost/mcp', { headers: { origin: 'http://evil.example.com' } });
const response = originValidationResponse(request, localhostAllowedOrigins());
expect(response).toBeDefined();
expect(response!.status).toBe(403);
const body = (await response!.json()) as { jsonrpc: string; error: { code: number; message: string }; id: unknown };
expect(body.jsonrpc).toBe('2.0');
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toContain('Invalid Origin');
expect(body.id).toBeNull();
});
});

View file

@ -0,0 +1,251 @@
/**
* Per-request streaming behavior: the lazy JSON-to-SSE upgrade, sink
* discipline (write order, drain-before-finalize, post-close drops), the
* forced response modes the entry-level knob will plug into, comment-frame
* support, and disconnect-as-cancellation.
*/
import type { CallToolResult, JSONRPCRequest, MessageClassification, ServerContext } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
setNegotiatedProtocolVersion
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import type { PerRequestResponseMode } from '../../src/server/perRequestTransport';
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
import { Server } from '../../src/server/server';
const MODERN_REVISION = '2026-07-28';
const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION };
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'streaming-test-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
const toolsCall = (id = 1): JSONRPCRequest =>
({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'echo', arguments: {}, _meta: ENVELOPE }
}) as JSONRPCRequest;
const progressNotification = (progress: number) => ({
method: 'notifications/progress' as const,
params: { progressToken: 'stream-test', progress }
});
interface StreamingSetup {
server: Server;
transport: PerRequestHTTPServerTransport;
}
async function setup(
handler: (ctx: ServerContext) => Promise<CallToolResult>,
responseMode?: PerRequestResponseMode
): Promise<StreamingSetup> {
const server = new Server({ name: 'streaming-test', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/call', async (_request, ctx) => handler(ctx));
setNegotiatedProtocolVersion(server, MODERN_REVISION);
const transport = new PerRequestHTTPServerTransport({
classification: MODERN,
...(responseMode !== undefined && { responseMode })
});
await server.connect(transport);
return { server, transport };
}
/** SSE frames of a fully-drained response body, split on the blank-line separator. */
async function sseFrames(response: Response): Promise<string[]> {
const text = await response.text();
return text
.split('\n\n')
.map(frame => frame.trim())
.filter(frame => frame.length > 0);
}
const dataOf = (frame: string): unknown => {
const dataLine = frame.split('\n').find(line => line.startsWith('data: '));
return dataLine === undefined ? undefined : JSON.parse(dataLine.slice('data: '.length));
};
describe('lazy upgrade matrix', () => {
it('answers a handler with no streamed output as a single JSON body', async () => {
const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'plain' }] }));
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('application/json');
expect(response.headers.get('x-accel-buffering')).toBeNull();
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('plain');
});
it('upgrades to SSE on the first related notification', async () => {
const { transport } = await setup(async ctx => {
await ctx.mcpReq.notify(progressNotification(1));
return { content: [{ type: 'text', text: 'streamed' }] };
});
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/event-stream');
expect(response.headers.get('cache-control')).toBe('no-cache');
expect(response.headers.get('x-accel-buffering')).toBe('no');
const frames = await sseFrames(response);
expect(frames).toHaveLength(2);
expect(dataOf(frames[0]!)).toMatchObject({ method: 'notifications/progress' });
expect(dataOf(frames[1]!)).toMatchObject({ id: 1, result: { content: [{ type: 'text', text: 'streamed' }] } });
});
it('drains every streamed message before the terminal result and then ends the stream', async () => {
const { transport } = await setup(async ctx => {
await ctx.mcpReq.notify(progressNotification(1));
await ctx.mcpReq.notify(progressNotification(2));
await ctx.mcpReq.notify(progressNotification(3));
return { content: [{ type: 'text', text: 'done' }] };
});
const response = await transport.handleMessage(toolsCall());
const frames = await sseFrames(response);
expect(frames).toHaveLength(4);
const progressValues = frames.slice(0, 3).map(frame => (dataOf(frame) as { params: { progress: number } }).params.progress);
expect(progressValues).toEqual([1, 2, 3]);
expect(dataOf(frames[3]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'done' }] } });
});
it('emits no resumability bytes: no event ids, no retry hints, no priming events', async () => {
const { transport } = await setup(async ctx => {
await ctx.mcpReq.notify(progressNotification(1));
return { content: [] };
});
const response = await transport.handleMessage(toolsCall());
const text = await response.text();
expect(text).not.toMatch(/^id:/m);
expect(text).not.toMatch(/^retry:/m);
expect(response.headers.get('mcp-session-id')).toBeNull();
});
it('drops writes after the exchange is closed', async () => {
// A streamed exchange whose stream has already been finalized: a late
// related write must be dropped by the closed-guard. If that guard
// were removed, the write would hit the closed stream controller and
// be reported through onerror.
const { transport } = await setup(async ctx => {
await ctx.mcpReq.notify(progressNotification(1));
return { content: [] };
});
const response = await transport.handleMessage(toolsCall());
await response.text();
await transport.close();
const errors: Error[] = [];
transport.onerror = error => errors.push(error);
await expect(transport.send(progressNotification(9) as never, { relatedRequestId: 1 })).resolves.toBeUndefined();
expect(errors).toHaveLength(0);
});
});
describe('forced response modes (the seam the entry-level knob plugs into)', () => {
it('sse mode opens the stream immediately, even with no streamed output', async () => {
const { transport } = await setup(async () => ({ content: [{ type: 'text', text: 'eager' }] }), 'sse');
const response = await transport.handleMessage(toolsCall());
expect(response.headers.get('content-type')).toBe('text/event-stream');
const frames = await sseFrames(response);
expect(frames).toHaveLength(1);
expect(dataOf(frames[0]!)).toMatchObject({ result: { content: [{ type: 'text', text: 'eager' }] } });
});
it('sse mode still answers pre-dispatch rejections with their mapped HTTP status', async () => {
// The forced-sse stream opens only after the pre-dispatch gates pass:
// a request the validation ladder rejects (here: an unknown method
// with no handler) keeps the spec-mandated HTTP status instead of
// being framed onto a 200 stream.
const { transport } = await setup(async () => ({ content: [] }), 'sse');
const unknownMethod = {
jsonrpc: '2.0',
id: 1,
method: 'definitely/unknown',
params: { _meta: ENVELOPE }
} as JSONRPCRequest;
const response = await transport.handleMessage(unknownMethod);
expect(response.status).toBe(404);
expect(response.headers.get('content-type')).toContain('application/json');
const body = (await response.json()) as { error?: { code: number } };
expect(body.error?.code).toBe(-32_601);
});
it('json mode never upgrades and drops mid-call notifications', async () => {
const { transport } = await setup(async ctx => {
await ctx.mcpReq.notify(progressNotification(1));
await ctx.mcpReq.notify(progressNotification(2));
return { content: [{ type: 'text', text: 'json-only' }] };
}, 'json');
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('application/json');
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('json-only');
// The notifications were dropped, not buffered into the body.
expect(JSON.stringify(body)).not.toContain('notifications/progress');
});
});
describe('comment frames', () => {
it('writes comment frames into an open stream and drops them otherwise', async () => {
let release!: () => void;
const gate = new Promise<void>(resolve => {
release = resolve;
});
const { transport } = await setup(async () => {
await gate;
return { content: [] };
}, 'sse');
const responsePromise = transport.handleMessage(toolsCall());
// The stream is open (sse mode settles once the pre-dispatch gates
// pass); a comment frame written now must be delivered to the
// consumer.
transport.writeCommentFrame('keep-alive');
release();
const response = await responsePromise;
const text = await response.text();
expect(text).toContain(': keep-alive');
// After the exchange completed (and the transport closed itself),
// comment frames are dropped silently — and never surface as stream
// write errors, which is what would happen without the closed-guard.
const errors: Error[] = [];
transport.onerror = error => errors.push(error);
transport.writeCommentFrame('late');
expect(errors).toHaveLength(0);
});
});
describe('disconnect is cancellation', () => {
it('cancelling the SSE stream aborts the in-flight handler', async () => {
let observedSignal: AbortSignal | undefined;
let abortObserved!: () => void;
const aborted = new Promise<void>(resolve => {
abortObserved = resolve;
});
const { transport } = await setup(async ctx => {
observedSignal = ctx.mcpReq.signal;
ctx.mcpReq.signal.addEventListener('abort', () => abortObserved(), { once: true });
await ctx.mcpReq.notify(progressNotification(1));
await aborted;
return { content: [] };
});
const response = await transport.handleMessage(toolsCall());
expect(response.headers.get('content-type')).toBe('text/event-stream');
const reader = response.body!.getReader();
await reader.read();
// The client goes away: cancelling the response stream tears the
// exchange down and aborts the handler's signal.
await reader.cancel();
await aborted;
expect(observedSignal?.aborted).toBe(true);
});
});

View file

@ -0,0 +1,394 @@
/**
* The per-request HTTP server transport: single-exchange contract, the
* classification handoff into protocol dispatch, HTTP status mapping for
* pre-handler rejections, auth-info pass-through, and the close/teardown
* chain.
*/
import type {
CallToolResult,
JSONRPCNotification,
JSONRPCRequest,
MessageClassification,
ServerContext
} from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
ProtocolError,
SdkError,
SdkErrorCode,
setNegotiatedProtocolVersion
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import * as z from 'zod/v4';
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
import { Server } from '../../src/server/server';
const MODERN_REVISION = '2026-07-28';
const MODERN: MessageClassification = { era: 'modern', revision: MODERN_REVISION };
const LEGACY: MessageClassification = { era: 'legacy' };
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'per-request-test-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
// `meta: null` builds an envelope-less request; the default is the full envelope.
const toolsCall = (id = 1, meta: Record<string, unknown> | null = ENVELOPE): JSONRPCRequest =>
({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'echo', arguments: {}, ...(meta !== null && { _meta: meta }) }
}) as JSONRPCRequest;
const envelopedRequest = (method: string, id = 1): JSONRPCRequest =>
({ jsonrpc: '2.0', id, method, params: { _meta: ENVELOPE } }) as JSONRPCRequest;
interface ServerSetup {
server: Server;
lastCtx: () => ServerContext | undefined;
}
function modernServer(options: { toolsCallHandler?: (ctx: ServerContext) => Promise<CallToolResult> } = {}): ServerSetup {
const server = new Server({ name: 'per-request-test', version: '1.0.0' }, { capabilities: { tools: {} } });
let captured: ServerContext | undefined;
const defaultHandler = async (): Promise<CallToolResult> => ({ content: [{ type: 'text', text: 'served' }] });
server.setRequestHandler('tools/call', async (_request, ctx) => {
captured = ctx;
return (options.toolsCallHandler ?? defaultHandler)(ctx);
});
setNegotiatedProtocolVersion(server, MODERN_REVISION);
return { server, lastCtx: () => captured };
}
async function connectedTransport(
server: Server,
options?: ConstructorParameters<typeof PerRequestHTTPServerTransport>[0]
): Promise<PerRequestHTTPServerTransport> {
const transport = new PerRequestHTTPServerTransport(options ?? { classification: MODERN });
await server.connect(transport);
return transport;
}
const errorOf = (body: unknown) => (body as { error?: { code: number; message: string; data?: unknown } }).error;
describe('single-exchange contract', () => {
it('throws when a message is handled before a server is connected', async () => {
const transport = new PerRequestHTTPServerTransport({ classification: MODERN });
await expect(transport.handleMessage(toolsCall())).rejects.toThrow(/not connected/);
});
it('serves exactly one exchange — a second handleMessage throws', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
const first = await transport.handleMessage(toolsCall());
expect(first.status).toBe(200);
await expect(transport.handleMessage(toolsCall(2))).rejects.toThrow(/exactly one exchange/);
});
it('cannot be started twice', async () => {
const transport = new PerRequestHTTPServerTransport({ classification: MODERN });
await transport.start();
await expect(transport.start()).rejects.toThrow(/already started/);
});
it('answers notification POST bodies with 202 and no body', async () => {
const { server } = modernServer();
let delivered: string | undefined;
server.fallbackNotificationHandler = async notification => {
delivered = notification.method;
};
const transport = await connectedTransport(server);
const response = await transport.handleMessage({ jsonrpc: '2.0', method: 'demo/heartbeat' } as JSONRPCNotification);
expect(response.status).toBe(202);
expect(await response.text()).toBe('');
await new Promise(resolve => setTimeout(resolve, 5));
expect(delivered).toBe('demo/heartbeat');
await transport.close();
await server.close();
});
});
describe('classification handoff into dispatch', () => {
it('serves a modern-classified request on a modern-marked instance', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('application/json');
const body = (await response.json()) as { result: { content: Array<{ text: string }> } };
expect(body.result.content[0]?.text).toBe('served');
});
it('answers legacy-classified traffic on a modern-marked instance with the protocol-version error and HTTP 400', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server, { classification: LEGACY });
server.onerror = () => {
// The mismatch is also surfaced out of band; irrelevant here.
};
const response = await transport.handleMessage(toolsCall(1, null));
expect(response.status).toBe(400);
const error = errorOf(await response.json());
expect(error?.code).toBe(-32_022);
expect(error?.data).toMatchObject({ requested: expect.any(String), supported: expect.any(Array) });
});
it('answers modern-classified traffic on an unmarked (legacy) instance with the protocol-version error', async () => {
const server = new Server({ name: 'unmarked', version: '1.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler('tools/call', async () => ({ content: [] }));
server.onerror = () => {
// The mismatch is also surfaced out of band; irrelevant here.
};
const transport = await connectedTransport(server, { classification: MODERN });
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(400);
expect(errorOf(await response.json())?.code).toBe(-32_022);
});
});
describe('HTTP status mapping', () => {
it('maps method-not-found for an era-removed method to HTTP 404', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
// `ping` exists on the 2025 era but has no entry on the 2026 registry.
const response = await transport.handleMessage(envelopedRequest('ping'));
expect(response.status).toBe(404);
expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' });
});
it('maps method-not-found for an unknown method with no handler to HTTP 404', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
const response = await transport.handleMessage(envelopedRequest('definitely/unknown'));
expect(response.status).toBe(404);
expect(errorOf(await response.json())?.code).toBe(-32_601);
});
it('keeps handler-produced errors in-band on HTTP 200, whatever their code', async () => {
const { server } = modernServer({
toolsCallHandler: async () => {
throw new ProtocolError(-32_002, 'resource missing');
}
});
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
// The encode seam maps −32002 → −32602 on the wire; what this test
// pins is that the error stays IN-BAND on HTTP 200.
expect(errorOf(await response.json())).toMatchObject({ code: -32_602, message: 'resource missing' });
});
it('keeps a handler-thrown method-not-found error in-band on HTTP 200 (the status table is origin-keyed)', async () => {
// A handler relaying a downstream -32601 (a proxy/relay tool is the
// realistic case) is a handler-produced error: it must not be
// re-mapped to HTTP 404 just because the ladder table maps that code
// for ladder-originated rejections.
const { server } = modernServer({
toolsCallHandler: async () => {
throw new ProtocolError(-32_601, 'Method not found');
}
});
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(errorOf(await response.json())).toMatchObject({ code: -32_601, message: 'Method not found' });
});
it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => {
const { server } = modernServer({
toolsCallHandler: async () => {
throw new ProtocolError(-32_022, 'Unsupported protocol version: 2099-01-01');
}
});
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(errorOf(await response.json())?.code).toBe(-32_022);
});
it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => {
const { server } = modernServer({
toolsCallHandler: async () => {
throw new ProtocolError(-32_602, 'bad arguments');
}
});
const transport = await connectedTransport(server);
const response = await transport.handleMessage(toolsCall());
expect(response.status).toBe(200);
expect(errorOf(await response.json())?.code).toBe(-32_602);
});
it('keeps the dispatch-level envelope check in-band: only the edge classifier maps invalid params to 400', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
// Modern-classified request without the _meta envelope: the dispatch
// layer rejects it with invalid params; the transport does not turn
// that into an HTTP-level failure.
const response = await transport.handleMessage(toolsCall(1, null));
expect(response.status).toBe(200);
expect(errorOf(await response.json())?.code).toBe(-32_602);
});
});
describe('auth info is strictly pass-through', () => {
it('never derives authInfo from the inbound request headers', async () => {
const { server, lastCtx } = modernServer();
const transport = await connectedTransport(server);
const request = new Request('http://localhost/mcp', {
method: 'POST',
headers: { authorization: 'Bearer super-secret-token', 'content-type': 'application/json' }
});
const response = await transport.handleMessage(toolsCall(), { request });
expect(response.status).toBe(200);
const ctx = lastCtx();
expect(ctx?.http?.req).toBe(request);
// The Authorization header is visible on the raw request, but it is
// never promoted to validated auth info by the transport.
expect(ctx?.http?.req?.headers.get('authorization')).toBe('Bearer super-secret-token');
expect(ctx?.http?.authInfo).toBeUndefined();
});
it('surfaces caller-provided authInfo unchanged', async () => {
const { server, lastCtx } = modernServer();
const transport = await connectedTransport(server);
const authInfo = { token: 'validated-token', clientId: 'client-1', scopes: ['mcp'] };
const response = await transport.handleMessage(toolsCall(), { authInfo });
expect(response.status).toBe(200);
expect(lastCtx()?.http?.authInfo).toEqual(authInfo);
});
});
describe('teardown and the close chain', () => {
it('close is idempotent and fires onclose exactly once', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
let closes = 0;
const previous = transport.onclose;
transport.onclose = () => {
closes += 1;
previous?.();
};
await transport.close();
await transport.close();
expect(closes).toBe(1);
});
it('server.close() and transport.close() do not re-enter each other', async () => {
const first = modernServer();
const firstTransport = await connectedTransport(first.server);
await first.server.close();
await firstTransport.close();
const second = modernServer();
const secondTransport = await connectedTransport(second.server);
await secondTransport.close();
await second.server.close();
});
it('closing mid-request rejects the pending response and aborts the handler', async () => {
let observedSignal: AbortSignal | undefined;
const { server } = modernServer({
toolsCallHandler: ctx => {
observedSignal = ctx.mcpReq.signal;
return new Promise<never>(() => {
// never resolves; the exchange is torn down externally
});
}
});
const transport = await connectedTransport(server);
const pending = transport.handleMessage(toolsCall());
const expectation = expect(pending).rejects.toSatisfy(
(error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed
);
await new Promise(resolve => setTimeout(resolve, 5));
await transport.close();
await expectation;
expect(observedSignal?.aborted).toBe(true);
});
it('an aborted request signal cancels the exchange', async () => {
let observedSignal: AbortSignal | undefined;
const { server } = modernServer({
toolsCallHandler: ctx => {
observedSignal = ctx.mcpReq.signal;
return new Promise<never>(() => {
// parked until the client goes away
});
}
});
const transport = await connectedTransport(server);
const abortController = new AbortController();
const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal });
const pending = transport.handleMessage(toolsCall(), { request });
const expectation = expect(pending).rejects.toSatisfy(
(error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed
);
await new Promise(resolve => setTimeout(resolve, 5));
abortController.abort();
await expectation;
expect(observedSignal?.aborted).toBe(true);
});
it('rejects with the typed connection-closed error when the request signal is already aborted', async () => {
const { server, lastCtx } = modernServer();
const transport = await connectedTransport(server);
const abortController = new AbortController();
abortController.abort();
const request = new Request('http://localhost/mcp', { method: 'POST', signal: abortController.signal });
await expect(transport.handleMessage(toolsCall(), { request })).rejects.toSatisfy(
(error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed
);
// The handler never ran; the exchange was torn down before dispatch.
expect(lastCtx()).toBeUndefined();
});
it('drops writes after close without raising or reporting through onerror', async () => {
const { server } = modernServer();
const transport = await connectedTransport(server);
await transport.close();
// If the closed-guard were removed, this response (for a request the
// transport never saw) would be reported through onerror as an
// unknown-request-id write.
const errors: Error[] = [];
transport.onerror = error => errors.push(error);
await expect(transport.send({ jsonrpc: '2.0', id: 1, result: {} }, { relatedRequestId: 1 })).resolves.toBeUndefined();
expect(errors).toHaveLength(0);
});
it('drops messages unrelated to the in-flight request', async () => {
const { server } = modernServer({
toolsCallHandler: async () => ({ content: [{ type: 'text', text: 'done' }] })
});
const transport = await connectedTransport(server);
const pending = transport.handleMessage(toolsCall());
// A session-wide notification with no related request has nowhere to
// go on a per-request exchange.
await transport.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
const response = await pending;
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('application/json');
});
});
describe('custom-method requests', () => {
it('serves custom (extension) methods registered with explicit schemas', async () => {
const { server } = modernServer();
server.setRequestHandler('app/echo', { params: z.looseObject({ value: z.string() }) }, async params => ({
echoed: params.value
}));
const transport = await connectedTransport(server);
const response = await transport.handleMessage({
jsonrpc: '2.0',
id: 4,
method: 'app/echo',
params: { value: 'hello', _meta: ENVELOPE }
} as JSONRPCRequest);
expect(response.status).toBe(200);
const body = (await response.json()) as { result: { echoed: string } };
expect(body.result.echoed).toBe('hello');
});
});

View file

@ -0,0 +1,178 @@
/**
* `createRequestStateCodec` — the opt-in HMAC-SHA256 sealing helper for the
* multi-round-trip `requestState` (SEP-2322). Pure unit tests of the codec;
* the seam-level wiring (`ServerOptions.requestState.verify`) is covered in
* `inputRequired.test.ts`.
*/
import type { ServerContext } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { createRequestStateCodec } from '../../src/server/requestStateCodec';
import type { ServerOptions } from '../../src/server/server';
const KEY = crypto.getRandomValues(new Uint8Array(32));
// Minimal stand-in for the bits of ServerContext the codec's `bind` callback
// reads — the codec itself never inspects ctx beyond passing it to `bind`.
const fakeCtx = (method: string, clientId?: string) =>
({ mcpReq: { method }, http: clientId === undefined ? undefined : { authInfo: { clientId } } }) as unknown as ServerContext;
describe('createRequestStateCodec', () => {
it('round-trips a JSON payload', async () => {
const codec = createRequestStateCodec<{ step: string; n: number }>({ key: KEY });
const wire = await codec.mint({ step: 'confirm', n: 42 });
expect(wire).toMatch(/^v1\.[-A-Za-z0-9_]+\.[-A-Za-z0-9_]+$/);
const payload = await codec.verify(wire, fakeCtx('tools/call'));
expect(payload).toEqual({ step: 'confirm', n: 42 });
});
it('rejects a tampered body with reason "mac"', async () => {
const codec = createRequestStateCodec({ key: KEY });
const wire = await codec.mint({ step: 'confirm' });
// Flip a base64url character in the body segment.
const dot = wire.lastIndexOf('.');
const tampered = `${wire.slice(0, 4)}${wire[4] === 'A' ? 'B' : 'A'}${wire.slice(5, dot)}${wire.slice(dot)}`;
await expect(codec.verify(tampered, fakeCtx('tools/call'))).rejects.toThrow('mac');
});
it('rejects a tampered MAC with reason "mac"', async () => {
const codec = createRequestStateCodec({ key: KEY });
const wire = await codec.mint({ step: 'confirm' });
const tampered = `${wire.slice(0, -2)}${wire.at(-2) === 'A' ? 'B' : 'A'}${wire.at(-1)}`;
await expect(codec.verify(tampered, fakeCtx('tools/call'))).rejects.toThrow('mac');
});
it('rejects values minted under a different key', async () => {
const codecA = createRequestStateCodec({ key: KEY });
const codecB = createRequestStateCodec({ key: crypto.getRandomValues(new Uint8Array(32)) });
const wire = await codecA.mint({ step: 'confirm' });
await expect(codecB.verify(wire, fakeCtx('tools/call'))).rejects.toThrow('mac');
});
it('rejects malformed envelopes (missing prefix / missing segments)', async () => {
const codec = createRequestStateCodec({ key: KEY });
await expect(codec.verify('not-v1.body.mac', fakeCtx('tools/call'))).rejects.toThrow('malformed');
await expect(codec.verify('v1.bodyonly', fakeCtx('tools/call'))).rejects.toThrow('malformed');
await expect(codec.verify('v1..mac', fakeCtx('tools/call'))).rejects.toThrow('malformed');
await expect(codec.verify('', fakeCtx('tools/call'))).rejects.toThrow('malformed');
});
it('rejects an expired value with reason "expired"', async () => {
// ttlSeconds: -1 stamps an exp already in the past; the MAC still
// verifies (we minted it), so the rejection is the expiry check.
const codec = createRequestStateCodec({ key: KEY, ttlSeconds: -1 });
const wire = await codec.mint({ step: 'confirm' });
await expect(codec.verify(wire, fakeCtx('tools/call'))).rejects.toThrow('expired');
});
describe('context binding', () => {
const bind = (ctx: ServerContext) =>
`${ctx.mcpReq.method}\0${(ctx.http?.authInfo as { clientId?: string } | undefined)?.clientId ?? ''}`;
it('round-trips when the binding value matches', async () => {
const codec = createRequestStateCodec<{ step: string }>({ key: KEY, bind });
const wire = await codec.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice'));
const payload = await codec.verify(wire, fakeCtx('tools/call', 'alice'));
expect(payload).toEqual({ step: 'confirm' });
});
it('rejects with reason "bind" when the binding value differs — message is opaque', async () => {
const codec = createRequestStateCodec({ key: KEY, bind });
const wire = await codec.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice'));
const rejection = await codec.verify(wire, fakeCtx('tools/call', 'mallory')).catch((e: Error) => e);
expect(rejection).toBeInstanceOf(Error);
// The thrown reason is a fixed code; neither principal identifier
// appears in the message (so onerror logging cannot leak them).
expect((rejection as Error).message).toBe('bind');
expect((rejection as Error).message).not.toContain('alice');
expect((rejection as Error).message).not.toContain('mallory');
});
it('mint without ctx throws when bind is configured', async () => {
const codec = createRequestStateCodec({ key: KEY, bind });
await expect(codec.mint({ step: 'confirm' })).rejects.toThrow(TypeError);
});
it('does not embed the raw binding value in the minted state (stored as a keyed tag)', async () => {
const codec = createRequestStateCodec({ key: KEY, bind });
const wire = await codec.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice'));
// The body segment is base64url(JSON) — decode it and confirm neither
// component of the raw `bind(ctx)` value (`tools/call\0alice`) appears.
// It is stored as a keyed HMAC tag instead, so the client cannot read
// the principal identifier out of the signed-not-encrypted envelope.
const body = wire.slice('v1.'.length, wire.lastIndexOf('.'));
const decoded = new TextDecoder().decode(
Uint8Array.from(atob(body.replaceAll('-', '+').replaceAll('_', '/')), c => c.codePointAt(0)!)
);
expect(decoded).not.toContain('alice');
expect(decoded).not.toContain('tools/call');
expect(JSON.parse(decoded)).toHaveProperty('b');
});
it('rejects a bound token when bind is unconfigured (fail-closed on config drift)', async () => {
const bound = createRequestStateCodec({ key: KEY, bind });
const unbound = createRequestStateCodec({ key: KEY });
const wire = await bound.mint({ step: 'confirm' }, fakeCtx('tools/call', 'alice'));
// Same key, MAC verifies — but the unconfigured instance must
// refuse a token that carries a `b` field rather than silently
// dropping the principal-binding guarantee.
await expect(unbound.verify(wire, fakeCtx('tools/call', 'alice'))).rejects.toThrow('bind');
});
});
it('snapshots a Uint8Array key at construction (caller mutation has no effect)', async () => {
const mutable = crypto.getRandomValues(new Uint8Array(32));
const codec = createRequestStateCodec({ key: mutable });
// Zero the caller's buffer AFTER construction (standard secret-hygiene).
// The codec already owns its own copy, so mint+verify still agree on
// the original key — and a second codec built from the zeroed buffer
// is a DIFFERENT key.
mutable.fill(0);
const wire = await codec.mint({ ok: true });
expect(await codec.verify(wire, fakeCtx('tools/call'))).toEqual({ ok: true });
const codecZero = createRequestStateCodec({ key: new Uint8Array(32) });
await expect(codecZero.verify(wire, fakeCtx('tools/call'))).rejects.toThrow('mac');
});
it('binds the version prefix into the MAC (a transplanted body.mac under a different prefix fails)', async () => {
const codec = createRequestStateCodec({ key: KEY });
const wire = await codec.mint({ ok: true });
// The MAC covers `"v1." + body`. The verifier hard-gates the prefix,
// so to assert the MAC binding we recompute it manually with `"v2."`
// over the same body and confirm the codec rejects that pair.
const body = wire.slice('v1.'.length, wire.lastIndexOf('.'));
const cryptoKey = await crypto.subtle.importKey('raw', KEY, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const v2Mac = new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(`v2.${body}`)));
const b64url = (b: Uint8Array) =>
btoa(String.fromCodePoint(...b))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
// Same body, MAC computed for a v2 prefix, presented under v1 — MAC fails.
await expect(codec.verify(`v1.${body}.${b64url(v2Mac)}`, fakeCtx('tools/call'))).rejects.toThrow('mac');
});
it('throws RangeError on a key shorter than 32 bytes', () => {
expect(() => createRequestStateCodec({ key: new Uint8Array(31) })).toThrow(RangeError);
expect(() => createRequestStateCodec({ key: 'short' })).toThrow(RangeError);
});
it('throws RangeError on a non-finite ttlSeconds (Infinity/NaN would mint never-expiring tokens)', () => {
expect(() => createRequestStateCodec({ key: KEY, ttlSeconds: Infinity })).toThrow(RangeError);
expect(() => createRequestStateCodec({ key: KEY, ttlSeconds: Number.NaN })).toThrow(RangeError);
});
it('accepts a 32-character string key (UTF-8 length)', async () => {
const codec = createRequestStateCodec({ key: 'a'.repeat(32) });
const wire = await codec.mint({ ok: true });
expect(await codec.verify(wire, fakeCtx('tools/call'))).toEqual({ ok: true });
});
it('codec.verify is directly assignable to ServerOptions.requestState.verify', () => {
// Type-level guard: the seam hook accepts any return so a verifier
// that also yields the decoded payload drops in directly.
const codec = createRequestStateCodec({ key: KEY });
const opts: ServerOptions = { requestState: { verify: codec.verify } };
expect(opts.requestState?.verify).toBe(codec.verify);
});
});

Some files were not shown because too many files have changed in this diff Show more