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:
parent
e4227d135c
commit
1823aae891
26 changed files with 69131 additions and 9001 deletions
272
packages/server/test/server/cacheHints.test.ts
Normal file
272
packages/server/test/server/cacheHints.test.ts
Normal 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"');
|
||||
}
|
||||
});
|
||||
});
|
||||
131
packages/server/test/server/classificationCarrierPin.test.ts
Normal file
131
packages/server/test/server/classificationCarrierPin.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
816
packages/server/test/server/createMcpHandler.test.ts
Normal file
816
packages/server/test/server/createMcpHandler.test.ts
Normal 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);
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
238
packages/server/test/server/createMcpHandlerListen.test.ts
Normal file
238
packages/server/test/server/createMcpHandlerListen.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
210
packages/server/test/server/discover.test.ts
Normal file
210
packages/server/test/server/discover.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
248
packages/server/test/server/eraParityErrorShapes.test.ts
Normal file
248
packages/server/test/server/eraParityErrorShapes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
455
packages/server/test/server/inputRequired.test.ts
Normal file
455
packages/server/test/server/inputRequired.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
139
packages/server/test/server/invokeSeam.test.ts
Normal file
139
packages/server/test/server/invokeSeam.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
118
packages/server/test/server/legacyDefaultServing.test.ts
Normal file
118
packages/server/test/server/legacyDefaultServing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
184
packages/server/test/server/legacyStatelessFallback.test.ts
Normal file
184
packages/server/test/server/legacyStatelessFallback.test.ts
Normal 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' }));
|
||||
});
|
||||
});
|
||||
51
packages/server/test/server/listOrdering.test.ts
Normal file
51
packages/server/test/server/listOrdering.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
114
packages/server/test/server/lowLevelLegacyWrap.test.ts
Normal file
114
packages/server/test/server/lowLevelLegacyWrap.test.ts
Normal 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]) });
|
||||
});
|
||||
});
|
||||
|
|
@ -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>>>>();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
137
packages/server/test/server/mcpParamValidation.test.ts
Normal file
137
packages/server/test/server/mcpParamValidation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
67
packages/server/test/server/originValidation.test.ts
Normal file
67
packages/server/test/server/originValidation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
251
packages/server/test/server/perRequestStreaming.test.ts
Normal file
251
packages/server/test/server/perRequestStreaming.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
394
packages/server/test/server/perRequestTransport.test.ts
Normal file
394
packages/server/test/server/perRequestTransport.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
178
packages/server/test/server/requestStateCodec.test.ts
Normal file
178
packages/server/test/server/requestStateCodec.test.ts
Normal 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
Loading…
Reference in a new issue