Implement MCP 2026-07-28 (#2286)

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,128 @@
/**
* Body-derived per-request headers on the streamable HTTP client transport:
* when a single outgoing request carries the 2026-07-28 protocol-version claim
* in its `_meta` envelope (the negotiation probe is the first such sender), the
* `MCP-Protocol-Version` and `Mcp-Method` headers derive from the message
* itself. The connection-level version slot is never consulted or mutated for
* those sends, and envelope-less (2025-era) traffic gets no new headers.
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
describe('body-derived probe headers', () => {
let transport: StreamableHTTPClientTransport;
let fetchSpy: ReturnType<typeof vi.fn>;
const okJson = (body: unknown) => ({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(body)
});
beforeEach(async () => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
await transport.start();
});
afterEach(async () => {
await transport.close().catch(() => {});
vi.restoreAllMocks();
});
const probeRequest: JSONRPCMessage = {
jsonrpc: '2.0',
id: 'server-discover-probe-1',
method: 'server/discover',
params: {
_meta: {
[PROTOCOL_VERSION_META_KEY]: '2026-07-28',
[CLIENT_INFO_META_KEY]: { name: 'c', version: '0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
}
}
};
const sentHeaders = (): Headers => {
const init = fetchSpy.mock.calls.at(-1)?.[1] as RequestInit;
return init.headers as Headers;
};
it('derives MCP-Protocol-Version and Mcp-Method from the probe message body', async () => {
fetchSpy.mockResolvedValueOnce(
okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } })
);
await transport.send(probeRequest);
const headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBe('2026-07-28');
expect(headers.get('mcp-method')).toBe('server/discover');
});
it('never mutates the transport version slot for body-derived sends', async () => {
fetchSpy.mockResolvedValueOnce(
okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } })
);
await transport.send(probeRequest);
expect(transport.protocolVersion).toBeUndefined();
// A follow-up envelope-less message gets no version header at all — the
// slot is still unset; nothing leaked from the probe.
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 0, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} });
const headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBeNull();
expect(headers.get('mcp-method')).toBeNull();
});
it('envelope-less (2025-era) requests are untouched: no 2026 headers, slot-driven behavior unchanged', async () => {
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 1, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} });
let headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBeNull();
expect(headers.get('mcp-method')).toBeNull();
expect(headers.get('mcp-name')).toBeNull();
// setProtocolVersion (the legacy post-initialize call site, byte-untouched)
// still drives the header for subsequent slot-based sends.
transport.setProtocolVersion('2025-11-25');
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 2, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} });
headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBe('2025-11-25');
expect(headers.get('mcp-method')).toBeNull();
});
it('a body-derived claim takes precedence over the slot for its own request only', async () => {
transport.setProtocolVersion('2025-11-25');
fetchSpy.mockResolvedValueOnce(
okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } })
);
await transport.send(probeRequest);
expect(sentHeaders().get('mcp-protocol-version')).toBe('2026-07-28');
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 3, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 3, method: 'ping', params: {} });
expect(sentHeaders().get('mcp-protocol-version')).toBe('2025-11-25');
});
it('batch (array) sends are never body-derived', async () => {
fetchSpy.mockResolvedValueOnce(okJson([{ jsonrpc: '2.0', id: 4, result: {} }]));
await transport.send([probeRequest as never]);
const headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBeNull();
expect(headers.get('mcp-method')).toBeNull();
});
});

View file

@ -0,0 +1,30 @@
/**
* Type-surface pins for the client's high-level methods.
*
* `callTool` returns plain `CallToolResult` on every protocol era — no task
* union (a v2 client never sends a task-augmented call, so a task result is
* unreachable from its API) and no wire-only members (`resultType` is
* consumed at the protocol layer and never reaches consumers).
*/
import type { CallToolResult, EmptyResult, ListToolsResult, ReadResourceResult } from '@modelcontextprotocol/core-internal';
import { describe, expectTypeOf, test } from 'vitest';
import { Client } from '../../src/client/client';
type KnownKeyOf<T> = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] };
describe('client method return types', () => {
test('callTool returns plain CallToolResult (no union, no wire-only members)', () => {
type Return = Awaited<ReturnType<Client['callTool']>>;
expectTypeOf<Return>().toEqualTypeOf<CallToolResult>();
expectTypeOf<Extract<KnownKeyOf<Return>, 'resultType'>>().toEqualTypeOf<never>();
expectTypeOf<Extract<KnownKeyOf<Return>, 'task'>>().toEqualTypeOf<never>();
});
test('the other request methods return the public result types', () => {
expectTypeOf<Awaited<ReturnType<Client['ping']>>>().toEqualTypeOf<EmptyResult>();
expectTypeOf<Awaited<ReturnType<Client['listTools']>>>().toEqualTypeOf<ListToolsResult>();
expectTypeOf<Awaited<ReturnType<Client['readResource']>>>().toEqualTypeOf<ReadResourceResult>();
expectTypeOf<Extract<KnownKeyOf<Awaited<ReturnType<Client['listTools']>>>, 'resultType'>>().toEqualTypeOf<never>();
});
});

View file

@ -0,0 +1,198 @@
/**
* `connect({ prior: DiscoverResult })` — zero-round-trip reconnect for the
* gateway / distributed-client pattern (issue #79). A previously-obtained
* `DiscoverResult` adopted directly: on a modern overlap nothing reaches the
* wire during connect; no modern overlap throws `EraNegotiationFailed` (no
* legacy fallback). Populates `getDiscoverResult()` (alongside the
* `'auto'`-mode probe path) and round-trips through JSON.
*/
import type { DiscoverResult, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
class ScriptedTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
sent: JSONRPCMessage[] = [];
setProtocolVersionCalls: string[] = [];
constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void = () => {}) {}
async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
queueMicrotask(() => this.script(message, this));
}
setProtocolVersion(version: string): void {
this.setProtocolVersionCalls.push(version);
}
reply(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}
const prior = (supportedVersions: string[]): DiscoverResult => ({
supportedVersions,
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'persisted-server', version: '1.0.0' },
instructions: 'persisted instructions'
});
describe('connect({ prior }) — modern overlap: zero round trips', () => {
test('nothing reaches the wire during connect; era state is the post-probe state', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' });
await client.connect(transport, { prior: prior([MODERN]) });
// ZERO requests sent during connect.
expect(transport.sent).toHaveLength(0);
// The transport's protocol-version slot is stamped exactly once.
expect(transport.setProtocolVersionCalls).toEqual([MODERN]);
// Adopted directly from prior.
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
expect(client.getProtocolEra()).toBe('modern');
expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: true } });
expect(client.getServerVersion()).toEqual({ name: 'persisted-server', version: '1.0.0' });
expect(client.getInstructions()).toBe('persisted instructions');
expect(client.getDiscoverResult()).toEqual(prior([MODERN]));
await client.close();
});
test('callTool works immediately after a zero-round-trip connect', async () => {
const transport = new ScriptedTransport((message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'tools/call') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] }
});
}
});
const client = new Client({ name: 'worker', version: '0' });
await client.connect(transport, { prior: prior([MODERN]) });
// First wire traffic is the tools/call itself.
expect(transport.sent).toHaveLength(0);
const result = await client.callTool({ name: 'echo' });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
const reqs = transport.sent.filter(isJSONRPCRequest);
expect(reqs).toHaveLength(1);
expect(reqs[0]!.method).toBe('tools/call');
await client.close();
});
test('prior bypasses versionNegotiation resolution (no probe even with mode: auto)', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport, { prior: prior([MODERN]) });
expect(transport.sent).toHaveLength(0);
await client.close();
});
});
describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)', () => {
test('legacy-only prior → SdkError(EraNegotiationFailed) steering to mode: auto', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' });
await expect(client.connect(transport, { prior: prior(['2025-06-18']) })).rejects.toSatisfy(
error =>
error instanceof SdkError &&
error.code === SdkErrorCode.EraNegotiationFailed &&
/2026-07-28\+ mutual/.test(error.message) &&
/mode: 'auto'/.test(error.message)
);
// Nothing reached the transport (the throw is before super.connect()).
expect(transport.sent).toHaveLength(0);
expect(client.getDiscoverResult()).toBeUndefined();
});
test('disjoint modern lists → SdkError(EraNegotiationFailed)', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' });
await expect(client.connect(transport, { prior: prior(['2099-01-01']) })).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
expect(transport.sent).toHaveLength(0);
});
});
describe('getDiscoverResult() round-trip', () => {
test("'auto'-mode probe populates it; JSON.stringify/parse round-trips into connect({ prior })", async () => {
// Bootstrap: a real probe against a scripted modern server.
const bootstrapTransport = new ScriptedTransport((message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: {
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'probed-server', version: '2.0.0' }
}
});
}
});
const bootstrap = new Client({ name: 'bootstrap', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(bootstrapTransport);
const probed = bootstrap.getDiscoverResult();
expect(probed?.serverInfo).toEqual({ name: 'probed-server', version: '2.0.0' });
expect(probed?.supportedVersions).toEqual([MODERN]);
await bootstrap.close();
// close() clears per-connection state.
expect(bootstrap.getDiscoverResult()).toBeUndefined();
// Persist + revive (the gateway pattern's "write to Redis/config" step).
const persisted = JSON.stringify(probed);
const revived = JSON.parse(persisted) as DiscoverResult;
// Worker: zero-round-trip connect from the revived blob.
const workerTransport = new ScriptedTransport();
const worker = new Client({ name: 'worker', version: '0' });
await worker.connect(workerTransport, { prior: revived });
expect(workerTransport.sent).toHaveLength(0);
expect(worker.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' });
expect(worker.getDiscoverResult()).toEqual(revived);
await worker.close();
});
test('discover() populates it on an already-connected modern client', async () => {
const transport = new ScriptedTransport((message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'public',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'rediscovered', version: '3.0.0' }
}
});
}
});
const client = new Client({ name: 'c', version: '0' });
await client.connect(transport, { prior: prior([MODERN]) });
expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server');
const fresh = await client.discover();
expect(fresh.serverInfo.name).toBe('rediscovered');
expect(client.getDiscoverResult()?.serverInfo.name).toBe('rediscovered');
await client.close();
});
});

View file

@ -1,10 +1,49 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { InsecureTokenEndpointError } from '../../src/client/authErrors';
import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess';
describe('crossAppAccess', () => {
describe('requestJwtAuthorizationGrant', () => {
it('rejects a non-https token endpoint before sending credentials (SEP-2207)', async () => {
const mockFetch = vi.fn<FetchLike>();
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'http://idp.internal/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow(InsecureTokenEndpointError);
expect(mockFetch).not.toHaveBeenCalled();
});
it('permits a loopback http token endpoint (SEP-2207 exemption)', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag',
token_type: 'N_A'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'http://localhost:3000/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
fetchFn: mockFetch
})
).resolves.toMatchObject({ jwtAuthGrant: 'jag' });
expect(mockFetch).toHaveBeenCalledOnce();
});
it('successfully exchanges ID token for JWT Authorization Grant', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
@ -34,7 +73,7 @@ describe('crossAppAccess', () => {
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://idp.example.com/token');
expect(String(url)).toBe('https://idp.example.com/token');
expect(init?.method).toBe('POST');
expect(init?.headers).toEqual({
'Content-Type': 'application/x-www-form-urlencoded'
@ -290,6 +329,20 @@ describe('crossAppAccess', () => {
});
describe('exchangeJwtAuthGrant', () => {
it('rejects a non-https token endpoint before sending credentials (SEP-2207)', async () => {
const mockFetch = vi.fn<FetchLike>();
await expect(
exchangeJwtAuthGrant({
tokenEndpoint: 'http://as.internal/token',
jwtAuthGrant: 'jwt',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow(InsecureTokenEndpointError);
expect(mockFetch).not.toHaveBeenCalled();
});
it('exchanges JAG for access token using client_secret_basic by default', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
@ -316,7 +369,7 @@ describe('crossAppAccess', () => {
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://auth.chat.example/token');
expect(String(url)).toBe('https://auth.chat.example/token');
expect(init?.method).toBe('POST');
// SEP-990 conformance: credentials in Authorization header, NOT in body

View file

@ -0,0 +1,101 @@
/**
* Typed `Client.discover()`: issues `server/discover` through the typed
* request funnel on a 2026-era connection; on a 2025-era connection the
* method does not exist (it is absent from the legacy registry), so the
* outbound era gate rejects it locally with a typed error before anything
* reaches the transport.
*/
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
class ScriptedTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
sent: JSONRPCMessage[] = [];
constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void) {}
async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
queueMicrotask(() => this.script(message, this));
}
setProtocolVersion(_version: string): void {}
reply(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}
const discoverBody = {
// A real 2026-era server stamps the resultType discriminator on the wire,
// and the 2026 wire shape carries the cacheable-result fields.
resultType: 'complete',
ttlMs: 0,
cacheScope: 'public',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'modern-server', version: '1.0.0' },
instructions: 'modern instructions'
};
/** Answers server/discover (probe and typed request alike) like a modern server. */
const modernScript = (message: JSONRPCMessage, t: ScriptedTransport) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({ jsonrpc: '2.0', id: message.id, result: discoverBody });
}
};
/** A plain 2025 server: answers initialize, -32601 for everything else. */
const legacyScript = (message: JSONRPCMessage, t: ScriptedTransport) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'initialize') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'legacy-server', version: '1.0.0' } }
});
} else {
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
}
};
describe('Client.discover()', () => {
test('issues a typed server/discover request on a 2026-era connection', async () => {
const transport = new ScriptedTransport(modernScript);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await client.connect(transport);
const advertisement = await client.discover();
expect(advertisement.supportedVersions).toEqual([MODERN]);
expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' });
expect(advertisement.instructions).toBe('modern instructions');
await client.close();
});
test('is rejected locally with a typed error on a 2025-era connection (the method does not exist on that era)', async () => {
const transport = new ScriptedTransport(legacyScript);
const client = new Client({ name: 'c', version: '0' });
await client.connect(transport);
const sentBefore = transport.sent.length;
await expect(client.discover()).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion
);
// Rejected locally: nothing new reached the transport.
expect(transport.sent.length).toBe(sentBefore);
await client.close();
});
});

View file

@ -0,0 +1,248 @@
/**
* Per-request `_meta` envelope auto-emission (protocol revision 2026-07-28):
* on a connection that negotiated the modern era — auto-negotiated or pinned —
* the client automatically attaches the reserved protocol-version /
* client-info / client-capabilities `_meta` keys to every outgoing request and
* notification. User-supplied `_meta` keys win over the auto-attached ones.
* Legacy-era connections never gain these keys (D9b byte-identity holds).
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
InMemoryTransport,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
const flush = () => new Promise(resolve => setTimeout(resolve, 20));
function metaOf(message: JSONRPCMessage): Record<string, unknown> | undefined {
const params = (message as { params?: { _meta?: Record<string, unknown> } }).params;
return params?._meta;
}
/**
* A scripted server side of an in-memory pair: answers `server/discover` (so a
* negotiating client lands on the modern era) or `initialize` (legacy era), and
* records everything the client writes.
*/
async function scriptedServerSide(era: 'modern' | 'legacy', answerToolsList = true) {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = message => {
written.push(message);
const request = message as { id?: number | string; method?: string };
if (request.method === 'server/discover' && request.id !== undefined) {
if (era === 'modern') {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'scripted-modern-server', version: '1.0.0' }
}
});
} else {
void serverTx.send({ jsonrpc: '2.0', id: request.id, error: { code: -32_601, message: 'Method not found' } });
}
return;
}
if (request.method === 'initialize' && request.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' }
}
});
return;
}
if (request.method === 'tools/list' && request.id !== undefined && answerToolsList) {
const result: Record<string, unknown> =
era === 'modern' ? { resultType: 'complete', tools: [], ttlMs: 0, cacheScope: 'public' } : { tools: [] };
void serverTx.send({ jsonrpc: '2.0', id: request.id, result });
}
};
await serverTx.start();
return { clientTx, written };
}
describe('per-request _meta envelope auto-emission on modern-era connections', () => {
it('attaches the reserved envelope keys to every outgoing request and notification', async () => {
const { clientTx, written } = await scriptedServerSide('modern');
const clientInfo = { name: 'envelope-client', version: '1.2.3' };
const client = new Client(clientInfo, {
versionNegotiation: { mode: 'auto' },
capabilities: { elicitation: { form: {} } }
});
await client.connect(clientTx);
expect(client.getProtocolEra()).toBe('modern');
await client.listTools();
await client.notification({ method: 'notifications/progress', params: { progressToken: 't', progress: 1 } });
await flush();
const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list');
expect(listToolsMessage).toBeDefined();
expect(metaOf(listToolsMessage!)).toEqual({
[PROTOCOL_VERSION_META_KEY]: MODERN,
[CLIENT_INFO_META_KEY]: clientInfo,
[CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }
});
const progressMessage = written.find(m => (m as { method?: string }).method === 'notifications/progress');
expect(progressMessage).toBeDefined();
expect(metaOf(progressMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN);
expect(metaOf(progressMessage!)?.[CLIENT_INFO_META_KEY]).toEqual(clientInfo);
await client.close();
});
it('reflects registered client capabilities in the auto-attached client-capabilities key', async () => {
const { clientTx, written } = await scriptedServerSide('modern');
const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } });
client.registerCapabilities({ sampling: {} });
await client.connect(clientTx);
await client.listTools();
const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list');
expect(metaOf(listToolsMessage!)?.[CLIENT_CAPABILITIES_META_KEY]).toEqual({ sampling: {} });
await client.close();
});
it('user-supplied _meta keys win over the auto-attached envelope keys; non-envelope keys are preserved', async () => {
const { clientTx, written } = await scriptedServerSide('modern');
const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await client.connect(clientTx);
await client.request({
method: 'tools/list',
params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 'consumer-override', 'x-consumer': 'kept' } }
});
const listToolsMessage = written.find(m => (m as { method?: string }).method === 'tools/list');
const meta = metaOf(listToolsMessage!);
expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe('consumer-override');
expect(meta?.['x-consumer']).toBe('kept');
// The other envelope keys are still auto-attached.
expect(meta?.[CLIENT_INFO_META_KEY]).toEqual({ name: 'envelope-client', version: '1.0.0' });
await client.close();
});
it('attaches the envelope to the cancellation notification of a modern-era request', async () => {
const { clientTx, written } = await scriptedServerSide('modern', /* answerToolsList */ false);
const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await client.connect(clientTx);
const controller = new AbortController();
const pending = client.listTools(undefined, { signal: controller.signal }).catch(() => {});
await flush();
controller.abort('test cancel');
await pending;
await flush();
const cancelMessage = written.find(m => (m as { method?: string }).method === 'notifications/cancelled');
expect(cancelMessage).toBeDefined();
expect(metaOf(cancelMessage!)?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN);
await client.close();
});
it('legacy-era connections never gain the envelope keys (byte-identity with a 2025 client)', async () => {
const { clientTx, written } = await scriptedServerSide('legacy');
const client = new Client({ name: 'envelope-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
expect(client.getProtocolEra()).toBe('legacy');
await client.listTools();
await flush();
// initialize, notifications/initialized, tools/list — none carry envelope keys.
const postProbe = written.filter(m => (m as { method?: string }).method !== 'server/discover');
expect(postProbe.length).toBeGreaterThanOrEqual(3);
for (const message of postProbe) {
const meta = metaOf(message);
expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined();
expect(meta?.[CLIENT_INFO_META_KEY]).toBeUndefined();
expect(meta?.[CLIENT_CAPABILITIES_META_KEY]).toBeUndefined();
}
await client.close();
});
it('the plain legacy default (no versionNegotiation) emits no envelope keys at all', async () => {
const { clientTx, written } = await scriptedServerSide('legacy');
const client = new Client({ name: 'envelope-client', version: '1.0.0' });
await client.connect(clientTx);
expect(client.getProtocolEra()).toBe('legacy');
await client.listTools();
await flush();
for (const message of written) {
const meta = metaOf(message);
expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined();
}
// initialize body matches today's plain client (no probe was ever sent).
expect(written.some(m => (m as { method?: string }).method === 'server/discover')).toBe(false);
await client.close();
});
});
describe('setVersionNegotiation()', () => {
it('configures negotiation pre-connect (equivalent to the constructor option)', async () => {
const { clientTx } = await scriptedServerSide('modern');
const client = new Client({ name: 'setter-client', version: '1.0.0' });
client.setVersionNegotiation({ mode: { pin: MODERN } });
await client.connect(clientTx);
expect(client.getProtocolEra()).toBe('modern');
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
await client.close();
});
it('throws after connecting to a transport', async () => {
const { clientTx } = await scriptedServerSide('legacy');
const client = new Client({ name: 'setter-client', version: '1.0.0' });
await client.connect(clientTx);
expect(() => client.setVersionNegotiation({ mode: 'auto' })).toThrow(/after connecting/);
await client.close();
});
it('passing undefined clears a previously configured negotiation (back to the legacy default)', async () => {
const { clientTx } = await scriptedServerSide('legacy');
const client = new Client({ name: 'setter-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
client.setVersionNegotiation(undefined);
await client.connect(clientTx);
expect(client.getProtocolEra()).toBe('legacy');
await client.close();
});
});
describe('getProtocolEra()', () => {
it('is undefined before connect, "legacy" after a 2025 handshake, "modern" after a 2026-07-28 negotiation', async () => {
const legacy = await scriptedServerSide('legacy');
const legacyClient = new Client({ name: 'era-client', version: '1.0.0' });
expect(legacyClient.getProtocolEra()).toBeUndefined();
await legacyClient.connect(legacy.clientTx);
expect(legacyClient.getProtocolEra()).toBe('legacy');
await legacyClient.close();
const modern = await scriptedServerSide('modern');
const modernClient = new Client({ name: 'era-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await modernClient.connect(modern.clientTx);
expect(modernClient.getProtocolEra()).toBe('modern');
await modernClient.close();
});
});

View file

@ -0,0 +1,374 @@
/**
* The client-side multi-round-trip engine end to end against a scripted
* modern (2026-07-28) server: auto-fulfilment via the already-registered
* handlers, fresh request ids per leg, byte-exact requestState echo, bare
* (never wrapped) inputResponses, multi-round flows, the round cap, manual
* mode, and the synthesized handler context contract.
*/
import type { ElicitResult, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { Client } from '../../src/client/client';
import type { ClientOptions } from '../../src/client/client';
const MODERN = '2026-07-28';
const ELICIT_ENTRY = {
method: 'elicitation/create',
params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } }
};
interface ScriptedServer {
clientTx: InMemoryTransport;
written: JSONRPCMessage[];
toolCalls: JSONRPCRequest[];
}
/**
* Scripted modern server: negotiates 2026-07-28 via server/discover and
* answers tools/call from the provided responder.
*/
async function scriptedModernServer(respondToToolCall: (request: JSONRPCRequest, call: number) => unknown): Promise<ScriptedServer> {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
const toolCalls: JSONRPCRequest[] = [];
serverTx.onmessage = message => {
written.push(message);
const request = message as JSONRPCRequest;
if (request.id === undefined) return;
if (request.method === 'server/discover') {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'scripted-mrtr-server', version: '1.0.0' }
}
});
return;
}
if (request.method === 'tools/call') {
toolCalls.push(request);
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: respondToToolCall(request, toolCalls.length)
} as Parameters<typeof serverTx.send>[0]);
}
};
await serverTx.start();
return { clientTx, written, toolCalls };
}
function makeClient(options?: ClientOptions): Client {
return new Client(
{ name: 'mrtr-engine-client', version: '1.0.0' },
{ versionNegotiation: { mode: { pin: MODERN } }, capabilities: { elicitation: { form: {} } }, ...options }
);
}
const COMPLETE_RESULT = { resultType: 'complete', content: [{ type: 'text', text: 'deployed' }] };
describe('auto-fulfilment (default on)', () => {
it('fulfils an elicitation via the registered handler and retries with a fresh id, bare responses, and a byte-exact requestState echo', async () => {
const { clientTx, toolCalls } = await scriptedModernServer((request, call) => {
if (call === 1) {
return { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'opaque-✓-state' };
}
// The retry must carry the responses; echo checked below.
expect(request.params).toMatchObject({ name: 'deploy' });
return COMPLETE_RESULT;
});
const client = makeClient();
const handled: unknown[] = [];
client.setRequestHandler('elicitation/create', async request => {
handled.push(request.params);
return { action: 'accept', content: { name: 'octocat' } } satisfies ElicitResult;
});
await client.connect(clientTx);
const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } });
expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]);
expect('resultType' in result).toBe(false);
// The handler saw the embedded request params.
expect(handled).toHaveLength(1);
expect(handled[0]).toMatchObject({ mode: 'form', message: 'What is your name?' });
// Two independent wire legs with fresh (different) ids.
expect(toolCalls).toHaveLength(2);
expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id);
// The retry carries the original params, the BARE response (no
// {method, result} wrapper), and the byte-exact requestState echo.
const retryParams = toolCalls[1]!.params as Record<string, unknown>;
expect(retryParams.name).toBe('deploy');
expect(retryParams.arguments).toEqual({ env: 'prod' });
expect(retryParams.inputResponses).toEqual({ github_login: { action: 'accept', content: { name: 'octocat' } } });
expect(retryParams.requestState).toBe('opaque-✓-state');
await client.close();
});
it('keeps the loop going across multiple rounds and omits requestState when a round carries none', async () => {
const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => {
if (call === 1) {
return { resultType: 'input_required', inputRequests: { first: ELICIT_ENTRY }, requestState: 'state-1' };
}
if (call === 2) {
return { resultType: 'input_required', inputRequests: { second: ELICIT_ENTRY } };
}
return COMPLETE_RESULT;
});
const client = makeClient();
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } }));
await client.connect(clientTx);
const result = await client.callTool({ name: 'deploy', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]);
expect(toolCalls).toHaveLength(3);
const secondRetry = toolCalls[2]!.params as Record<string, unknown>;
expect(Object.keys(secondRetry.inputResponses as Record<string, unknown>)).toEqual(['second']);
// The second input_required carried no requestState — the retry MUST NOT include one.
expect('requestState' in secondRetry).toBe(false);
await client.close();
});
it('exhausting the round cap raises the typed rounds-exceeded error carrying the last result', async () => {
const { clientTx, toolCalls } = await scriptedModernServer(() => ({
resultType: 'input_required',
inputRequests: { again: ELICIT_ENTRY },
requestState: 'still-going'
}));
const client = makeClient({ inputRequired: { maxRounds: 2 } });
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } }));
await client.connect(clientTx);
const outcome = client.callTool({ name: 'deploy', arguments: {} });
await expect(outcome).rejects.toSatisfy((error: unknown) => {
expect(error).toBeInstanceOf(SdkError);
const typed = error as SdkError;
expect(typed.code).toBe(SdkErrorCode.InputRequiredRoundsExceeded);
expect(typed.data).toMatchObject({ rounds: 2, lastResult: { requestState: 'still-going' } });
return true;
});
// Cap 2 ⇒ the original call plus exactly two retries reached the wire... no:
// the cap counts ROUNDS (retries); round 3 is never started, so the wire
// saw the original call + 2 retries.
expect(toolCalls).toHaveLength(3);
await client.close();
});
it('fails the call with a typed error when a required handler is not registered (reject, do not guess)', async () => {
const { clientTx } = await scriptedModernServer(() => ({
resultType: 'input_required',
inputRequests: { sample: { method: 'sampling/createMessage', params: { messages: [], maxTokens: 5 } } }
}));
const client = makeClient();
await client.connect(clientTx);
await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({
code: SdkErrorCode.CapabilityNotSupported,
data: { key: 'sample', method: 'sampling/createMessage' }
});
await client.close();
});
it('validates a forked, tool-bearing embedded sampling response against the 2026 in-band response schema', async () => {
const SAMPLING_WITH_TOOLS_ENTRY = {
method: 'sampling/createMessage',
params: {
messages: [{ role: 'user', content: { type: 'text', text: 'What is the weather in Berlin?' } }],
maxTokens: 200,
tools: [{ name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } }]
}
};
// Forked 2026 vocabulary: array content with a tool_use block and a
// tool_result block whose structuredContent is NOT an object (the
// 2026 anchor allows any value there; the 2025 result schemas do not).
// This pins that the embedded response is validated against the era's
// in-band response schema, mirroring the request-side selection.
const TOOL_BEARING_RESPONSE = {
model: 'test-model-1',
role: 'assistant' as const,
stopReason: 'toolUse',
content: [
{ type: 'tool_use' as const, name: 'get_weather', id: 'call-1', input: { city: 'Berlin' } },
{
type: 'tool_result' as const,
toolUseId: 'call-0',
content: [{ type: 'text' as const, text: '21°C' }],
structuredContent: 21
}
]
};
const { clientTx, toolCalls } = await scriptedModernServer((_request, call) =>
call === 1 ? { resultType: 'input_required', inputRequests: { weather: SAMPLING_WITH_TOOLS_ENTRY } } : COMPLETE_RESULT
);
const client = makeClient({ capabilities: { sampling: { tools: {} } } });
// The non-object structuredContent is deliberately outside the 2025
// result types (it is the 2026 fork) — hence the cast.
client.setRequestHandler('sampling/createMessage', async () => TOOL_BEARING_RESPONSE as never);
await client.connect(clientTx);
const result = await client.callTool({ name: 'deploy', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'deployed' }]);
// The retry carries the bare tool-bearing response unchanged.
expect(toolCalls).toHaveLength(2);
const retryParams = toolCalls[1]!.params as { inputResponses?: Record<string, unknown> };
expect(retryParams.inputResponses?.weather).toEqual(TOOL_BEARING_RESPONSE);
await client.close();
});
it('counts the first wire leg against maxTotalTimeout (the budget bounds the whole flow)', async () => {
let now = 1_000_000;
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now);
try {
const { clientTx, toolCalls } = await scriptedModernServer((_request, call) => {
// The first leg alone "takes" longer than the whole-flow budget.
now += 10_000;
return call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT;
});
const client = makeClient();
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } }));
await client.connect(clientTx);
await expect(
client.callTool({ name: 'deploy', arguments: {} }, { timeout: 60_000, maxTotalTimeout: 5_000 })
).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout, data: { maxTotalTimeout: 5_000 } });
// The flow failed before any retry reached the wire.
expect(toolCalls).toHaveLength(1);
await client.close();
} finally {
nowSpy.mockRestore();
}
});
it('fails fast with a typed error when input_required carries neither inputRequests nor requestState', async () => {
const { clientTx, toolCalls } = await scriptedModernServer(() => ({ resultType: 'input_required' }));
const client = makeClient();
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } }));
await client.connect(clientTx);
await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({
code: SdkErrorCode.InvalidResult,
data: { method: 'tools/call', violation: 'input-required-missing-both' }
});
// Fail fast: the original params are never resent until the cap runs out.
expect(toolCalls).toHaveLength(1);
await client.close();
});
it('fails the call with a typed error for an unknown embedded request kind', async () => {
const { clientTx } = await scriptedModernServer(() => ({
resultType: 'input_required',
inputRequests: { weird: { method: 'tasks/create', params: {} } }
}));
const client = makeClient();
await client.connect(clientTx);
await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({
code: SdkErrorCode.InvalidResult,
data: { key: 'weird', method: 'tasks/create' }
});
await client.close();
});
it('gives the embedded handler the synthesized context: correlation-only id, chained signal, send/notify unavailable', async () => {
const { clientTx } = await scriptedModernServer((_request, call) =>
call === 1 ? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY } } : COMPLETE_RESULT
);
const client = makeClient();
const seenCtx: unknown[] = [];
client.setRequestHandler('elicitation/create', async (_request, ctx) => {
seenCtx.push(ctx);
expect(ctx.mcpReq.id).toBe('github_login');
expect(ctx.mcpReq.method).toBe('elicitation/create');
expect(ctx.mcpReq.signal.aborted).toBe(false);
expect(() => ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 1, progress: 1 } })).toThrow(
/not available/
);
expect(() => ctx.mcpReq.send({ method: 'ping' })).toThrow(/not available/);
return { action: 'accept', content: { name: 'octocat' } };
});
await client.connect(clientTx);
await client.callTool({ name: 'deploy', arguments: {} });
expect(seenCtx).toHaveLength(1);
await client.close();
});
});
describe('manual mode', () => {
it('autoFulfill: false surfaces input_required as a typed error (no retries hit the wire)', async () => {
const { clientTx, toolCalls } = await scriptedModernServer(() => ({
resultType: 'input_required',
inputRequests: { github_login: ELICIT_ENTRY }
}));
const client = makeClient({ inputRequired: { autoFulfill: false } });
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'octocat' } }));
await client.connect(clientTx);
await expect(client.callTool({ name: 'deploy', arguments: {} })).rejects.toMatchObject({
code: SdkErrorCode.UnsupportedResultType,
data: { resultType: 'input_required', method: 'tools/call' }
});
expect(toolCalls).toHaveLength(1);
await client.close();
});
it('allowInputRequired: true hands the input-required value back to the caller, who can retry manually', async () => {
const { clientTx, toolCalls } = await scriptedModernServer((_request, call) =>
call === 1
? { resultType: 'input_required', inputRequests: { github_login: ELICIT_ENTRY }, requestState: 'manual-state' }
: COMPLETE_RESULT
);
const client = makeClient({ inputRequired: { autoFulfill: false } });
await client.connect(clientTx);
const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record<
string,
unknown
>;
expect(first.resultType).toBe('input_required');
expect(first.requestState).toBe('manual-state');
// The caller drives the retry itself: same params + responses + echo.
const second = await client.callTool({
name: 'deploy',
arguments: {},
inputResponses: { github_login: { action: 'accept', content: { name: 'octocat' } } },
requestState: first.requestState as string
} as Parameters<Client['callTool']>[0]);
expect(second.content).toEqual([{ type: 'text', text: 'deployed' }]);
expect(toolCalls).toHaveLength(2);
expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id);
await client.close();
});
});

View file

@ -48,6 +48,12 @@ async function connectInitializedClient(client: Client) {
]
}
} satisfies JSONRPCMessage);
} else if ('method' in message && 'id' in message && message.method === 'tools/call') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: { content: [], structuredContent: { count: 42 } }
} satisfies JSONRPCMessage);
}
};
@ -56,7 +62,7 @@ async function connectInitializedClient(client: Client) {
}
describe('client JSON Schema validator overrides', () => {
test('Client constructor uses a custom validator for tool output schema caching', async () => {
test('Client uses the custom validator for tool output validation (derived from the cached tools/list entry)', async () => {
const validator = new RecordingValidator();
const client = new Client(
{ name: 'test-client', version: '1.0.0' },
@ -67,6 +73,8 @@ describe('client JSON Schema validator overrides', () => {
);
const { clientTransport, serverTransport } = await connectInitializedClient(client);
// The validator index reads the cached `tools/list` entry; populate it
// via the public auto-aggregating listTools().
await expect(client.listTools()).resolves.toMatchObject({
tools: [
{
@ -80,6 +88,14 @@ describe('client JSON Schema validator overrides', () => {
]
});
// Derived-view behavior: the validator index re-derives lazily on the
// first callTool against the cached entry's stamp — populating the
// cache alone does not compile.
expect(validator.schemas).toEqual([]);
await expect(client.callTool({ name: 'structured-tool' })).resolves.toMatchObject({
structuredContent: { count: 42 }
});
expect(validator.schemas).toEqual([
{
type: 'object',
@ -87,12 +103,100 @@ describe('client JSON Schema validator overrides', () => {
required: ['count']
}
]);
expect(validator.values).toEqual([{ count: 42 }]);
// Same backing entry stamp → memoized; a second callTool does not recompile.
await client.callTool({ name: 'structured-tool' });
expect(validator.schemas).toHaveLength(1);
await client.close();
await clientTransport.close();
await serverTransport.close();
});
describe('outputSchema compile-error lifecycle (substrate-held; no parallel map)', () => {
// SEP-2106 §invalid-outputSchema: a tool whose outputSchema fails to compile is
// surfaced as a typed InvalidParams BEFORE the request is sent. The compile error is
// held on the response-cache substrate's stamp-keyed `name → validator` index, so it
// inherits that substrate's invalidation lifecycle — a refetched `tools/list` re-derives
// it from scratch (no stale-entry bug when the server fixes the tool by removing the
// schema). The caller-supplied `toolDefinition` path is compiled in isolation and never
// touches the cache, so a one-off bad definition cannot poison the listed tool.
async function connectMutableToolsClient(getTools: () => unknown[]) {
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
serverTransport.onmessage = async message => {
if (!('method' in message) || !('id' in message)) return;
if (message.method === 'initialize') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: 'test-server', version: '1.0.0' }
}
});
} else if (message.method === 'tools/list') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: { tools: getTools() }
} satisfies JSONRPCMessage);
} else if (message.method === 'tools/call') {
await serverTransport.send({
jsonrpc: '2.0',
id: message.id,
result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { count: 1 } }
} satisfies JSONRPCMessage);
}
};
await Promise.all([client.connect(clientTransport), serverTransport.start()]);
return { client, close: () => Promise.all([client.close(), clientTransport.close(), serverTransport.close()]) };
}
// An external `$ref` throws at compile time inside Ajv (MissingRefError — no fetch is
// attempted) and is captured per-tool by `_compileOutputValidator`.
const BAD_SCHEMA = { type: 'object', $ref: 'https://example.invalid/schema.json' } as const;
const GOOD_SCHEMA = { type: 'object', properties: { count: { type: 'number' } } } as const;
test('re-advertising a tool WITHOUT the bad outputSchema clears the captured failure', async () => {
let tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA }];
const { client, close } = await connectMutableToolsClient(() => tools);
await client.listTools();
await expect(client.callTool({ name: 't' })).rejects.toThrow(/invalid outputSchema/);
// Server fixes the tool by removing outputSchema entirely; refetched `tools/list`
// re-derives the index from scratch — no stale compile-error entry survives.
tools = [{ name: 't', inputSchema: { type: 'object' } }];
await client.listTools();
await expect(client.callTool({ name: 't' })).resolves.toMatchObject({
content: [{ type: 'text', text: 'ok' }]
});
await close();
});
test('a one-off `toolDefinition` with a bad outputSchema does not poison the listed tool', async () => {
const tools: unknown[] = [{ name: 't', inputSchema: { type: 'object' }, outputSchema: GOOD_SCHEMA }];
const { client, close } = await connectMutableToolsClient(() => tools);
await client.listTools();
await expect(
client.callTool({ name: 't' }, { toolDefinition: { name: 't', inputSchema: { type: 'object' }, outputSchema: BAD_SCHEMA } })
).rejects.toThrow(/invalid outputSchema/);
// Subsequent plain callTool of the same name (against the cached, valid listed
// schema) succeeds — the one-off definition never entered the cache.
await expect(client.callTool({ name: 't' })).resolves.toMatchObject({
structuredContent: { count: 1 }
});
await close();
});
});
test('fromJsonSchema uses an explicitly supplied custom validator', async () => {
const validator = new RecordingValidator();
const schema: JsonSchemaType = {

View file

@ -0,0 +1,47 @@
/**
* Plain-path guard for modern-only supported-versions lists: a Client
* constructed WITHOUT versionNegotiation must never offer a 2026-era revision
* through the legacy `initialize` handshake. With no 2025-era entry to offer,
* connect() rejects with the typed negotiation error before anything reaches
* the wire — independently of the same guard on the auto-negotiation path.
*/
import type { JSONRPCMessage, MessageExtraInfo, Transport } from '@modelcontextprotocol/core-internal';
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { Client } from '../../src/client/client';
function recordingTransport(): Transport & { sent: JSONRPCMessage[] } {
const sent: JSONRPCMessage[] = [];
return {
sent,
async start() {
// nothing to start
},
async send(message: JSONRPCMessage) {
sent.push(message);
},
async close() {
// nothing to close
},
onclose: undefined,
onerror: undefined,
onmessage: undefined as ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined
};
}
describe('plain client with a modern-only supported-versions list', () => {
test.each([
{ label: "['2026-07-28']", supportedProtocolVersions: ['2026-07-28'] },
{ label: '[] (empty list)', supportedProtocolVersions: [] as string[] }
])('connect() rejects with the typed negotiation error and never sends initialize — $label', async ({ supportedProtocolVersions }) => {
const transport = recordingTransport();
const client = new Client({ name: 'modern-only-client', version: '1.0.0' }, { supportedProtocolVersions });
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
expect(transport.sent.filter(message => 'method' in message && message.method === 'initialize')).toHaveLength(0);
});
});

View file

@ -0,0 +1,984 @@
/**
* `Client.listen()` — the `subscriptions/listen` driver (protocol revision
* 2026-07-28). Covers ack-resolved-promise, change-notification dispatch to
* existing setNotificationHandler registrations, the F-12 legacy-era steer,
* transport-agnostic close (always sends notifications/cancelled), inbound
* server-side cancel, and ClientOptions.listChanged auto-open on a modern
* connection.
*/
import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core-internal';
import {
InMemoryTransport,
LATEST_PROTOCOL_VERSION,
PROTOCOL_VERSION_META_KEY,
SdkError,
SdkErrorCode,
SUBSCRIPTION_ID_META_KEY
} from '@modelcontextprotocol/core-internal';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
const flush = () => new Promise(r => setTimeout(r, 10));
async function scriptedModern(onListen?: (id: number | string, filter: unknown, send: (m: JSONRPCMessage) => void) => void) {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = message => {
written.push(message);
const req = message as { id?: number | string; method?: string; params?: { notifications?: unknown } };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } },
serverInfo: { name: 'scripted', version: '1' }
}
});
}
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
const filter = req.params?.notifications ?? {};
const ack: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'notifications/subscriptions/acknowledged',
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: req.id }, notifications: filter }
};
void serverTx.send(ack);
onListen?.(req.id, filter, m => void serverTx.send(m));
}
};
await serverTx.start();
return { clientTx, serverTx, written };
}
/**
* Like `scriptedModern` but does NOT auto-ack `subscriptions/listen`: the
* test drives ack / cancel / transport-close itself.
*/
async function scriptedModernNoAck() {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = message => {
written.push(message);
const req = message as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } },
serverInfo: { name: 'scripted', version: '1' }
}
});
}
};
await serverTx.start();
return { clientTx, serverTx, written };
}
describe('Client.listen()', () => {
it('throws a typed steer on a legacy-era connection (no wire write)', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = m => {
written.push(m);
const req = m as { id?: number | string; method?: string };
if (req.method === 'initialize' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, serverInfo: { name: 's', version: '1' } }
});
}
};
await serverTx.start();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'legacy' } });
await client.connect(clientTx);
written.length = 0;
const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion);
expect((error as SdkError).message).toContain('resources/subscribe');
expect((error as SdkError).message).toContain('listChanged');
// The steer fires before any wire write.
expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false);
await client.close();
});
it('resolves on ack with the honored filter; change notifications reach setNotificationHandler', async () => {
let send!: (m: JSONRPCMessage) => void;
const { clientTx } = await scriptedModern((_id, _f, s) => {
send = s;
});
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
const seen: string[] = [];
client.setNotificationHandler('notifications/tools/list_changed', () => {
seen.push('tools');
});
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
expect(sub.honoredFilter).toEqual({ toolsListChanged: true });
send({
jsonrpc: '2.0',
method: 'notifications/tools/list_changed',
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 0 } }
});
await flush();
expect(seen).toEqual(['tools']);
await sub.close();
await client.close();
});
it('close() sends notifications/cancelled referencing the listen id on any transport', async () => {
// Plain InMemoryTransport (neither child-process nor SSE-stream
// semantics): close() must NOT depend on transport-kind detection —
// it always sends notifications/cancelled, so a spec-compliant server
// on InMemory / SSE / a custom transport tears the subscription down.
const { clientTx, written } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id;
written.length = 0;
await sub.close();
expect(written).toHaveLength(1);
const cancel = written[0] as unknown as { method: string; params: { requestId: unknown; _meta?: Record<string, unknown> } };
expect(cancel.method).toBe('notifications/cancelled');
expect(cancel.params.requestId).toBe(listenId);
// The listen-path cancel carries the same modern auto-envelope as
// every other outbound (request()'s cancel, Protocol.notification()).
expect(cancel.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN);
// Idempotent.
await sub.close();
expect(written).toHaveLength(1);
await client.close();
});
it("inbound notifications/cancelled post-ack: closed resolves 'remote'; subscription torn down; handlers stop firing", async () => {
let listenId!: number | string;
let send!: (m: JSONRPCMessage) => void;
const { clientTx } = await scriptedModern((id, _f, s) => {
listenId = id;
send = s;
});
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
const seen: string[] = [];
client.setNotificationHandler('notifications/tools/list_changed', () => {
seen.push('tools');
});
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
// The spec-defined remote termination signal is now observable on the
// subscription handle; settle() is the funnel and resolves it once.
await expect(sub.closed).resolves.toBe('remote');
// Per-listen state is gone; the request signal was aborted (so an HTTP
// SSE reader would have stopped).
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
// After a server-side close, the server stops delivering on this stream
// — a notification carrying this subscription id is no longer routed
// through any per-listen entry (the entry is gone). The handler is the
// shared setNotificationHandler registration; assert no later
// dispatch from THIS subscription's stream by asserting no entry exists
// to demux it.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.has(listenId)).toBe(false);
expect(seen).toEqual([]);
// close() after server-cancel is idempotent and does NOT change the
// already-resolved cause.
await sub.close();
await expect(sub.closed).resolves.toBe('remote');
await client.close();
});
it("close() resolves closed with 'local' exactly once", async () => {
const { clientTx } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
await sub.close();
await expect(sub.closed).resolves.toBe('local');
// A second close() and a later remote signal cannot change it.
await sub.close();
await expect(sub.closed).resolves.toBe('local');
await client.close();
});
it('closed resolves exactly once even when multiple termination signals arrive', async () => {
let listenId!: number | string;
let send!: (m: JSONRPCMessage) => void;
const { clientTx, serverTx } = await scriptedModern((id, _f, s) => {
listenId = id;
send = s;
});
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
const resolutions: string[] = [];
void sub.closed.then(cause => resolutions.push(cause));
// Three signals in quick succession: server-cancel, a duplicate
// server-cancel, then transport close. settle()'s `closed` guard
// means only the first transitions; `closed` resolves once.
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
await serverTx.close();
await flush();
expect(resolutions).toEqual(['remote']);
// sub.close() after the fact is still idempotent and cannot flip it.
await sub.close();
await expect(sub.closed).resolves.toBe('remote');
});
it('rejects with the typed pre-ack error when the server answers -32603', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = m => {
const req = m as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: {},
serverInfo: { name: 's', version: '1' }
}
});
}
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } });
}
};
await serverTx.start();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error);
expect(error).toBeInstanceOf(Error);
expect((error as { code?: number }).code).toBe(-32_603);
await client.close();
});
it('server cancels BEFORE the ack: listen() rejects immediately, no 60s hang', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = m => {
const req = m as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: {},
serverInfo: { name: 's', version: '1' }
}
});
}
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
// Server cancels the listen id BEFORE sending the ack.
void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } });
}
};
await serverTx.start();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const t0 = Date.now();
const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('server cancelled the subscription');
// Rejected promptly (well under the 60s ack timeout).
expect(Date.now() - t0).toBeLessThan(1000);
// No leaked per-listen state for the listen id.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it('an ack arriving AFTER the subscription was server-cancelled is a no-op', async () => {
let listenId!: number | string;
let send!: (m: JSONRPCMessage) => void;
const { clientTx } = await scriptedModern((id, _f, s) => {
listenId = id;
send = s;
});
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
// Server tears the open subscription down.
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
await flush();
// A late duplicate ack must not throw or resurrect state.
send({
jsonrpc: '2.0',
method: 'notifications/subscriptions/acknowledged',
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} }
});
await flush();
await sub.close();
await client.close();
});
it('a synchronously-delivered server-cancel during send does not leak a _listenState entry', async () => {
// In-process delivery: the server's notifications/cancelled arrives
// inside `transport.send()` (before the `await opening`). settle()
// must still drop the `_listenState` entry registered before send.
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = m => {
const req = m as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: {},
serverInfo: { name: 's', version: '1' }
}
});
}
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } });
}
};
await serverTx.start();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const listenState = (client as unknown as { _listenState: Map<unknown, unknown> })._listenState;
const before = listenState.size;
const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error);
expect((error as Error).message).toContain('server cancelled the subscription');
// No leaked _listenState entry for the listen id.
expect(listenState.size).toBe(before);
await client.close();
});
it('a synchronous transport.send throw does not leak a _listenState entry', async () => {
const { clientTx } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const realSend = clientTx.send.bind(clientTx);
clientTx.send = () => {
throw new Error('send blew up');
};
const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error);
expect((error as Error).message).toContain('send blew up');
// settle() in the catch path dropped the _listenState entry that was
// registered before send threw; listen() never registers in
// Protocol's `_responseHandlers` so there is nothing to leak there.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
expect((client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers.size).toBe(0);
clientTx.send = realSend;
await client.close();
});
it('options.signal already aborted: listen() rejects with SdkError(RequestTimeout) before any setup (parity with request())', async () => {
const { clientTx, written } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
written.length = 0;
const ac = new AbortController();
ac.abort('user cancelled');
const error = await client.listen({ toolsListChanged: true }, { signal: ac.signal }).catch(e => e as SdkError);
// Same wrap as `Protocol.request()` / `_serveFromCache`: a non-SdkError
// reason is wrapped as RequestTimeout; the reason text is preserved.
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
expect((error as SdkError).message).toContain('user cancelled');
// No subscriptions/listen reached the wire; no listen state registered.
await flush();
expect(written.find(m => (m as { method?: string }).method === 'subscriptions/listen')).toBeUndefined();
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
// An SdkError reason is preserved verbatim (not double-wrapped).
const ac2 = new AbortController();
const own = new SdkError(SdkErrorCode.NotConnected, 'upstream');
ac2.abort(own);
const error2 = await client.listen({ toolsListChanged: true }, { signal: ac2.signal }).catch(e => e as SdkError);
expect(error2).toBe(own);
await client.close();
});
it('options.signal aborted while opening: listen() rejects fast with the signal reason', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = m => {
written.push(m);
const req = m as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: {},
serverInfo: { name: 's', version: '1' }
}
});
}
// No ack for subscriptions/listen — stays in `opening`.
};
await serverTx.start();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const ac = new AbortController();
const t0 = Date.now();
const pending = client.listen({ toolsListChanged: true }, { signal: ac.signal });
ac.abort(new Error('caller-abort'));
const error = await pending.catch(e => e as Error);
expect((error as Error).message).toBe('caller-abort');
expect(Date.now() - t0).toBeLessThan(1000);
// wireTeardown sent notifications/cancelled referencing the listen id.
await flush();
const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id;
const cancelled = written.find(m => (m as { method?: string }).method === 'notifications/cancelled') as
| { params: { requestId: unknown } }
| undefined;
expect(cancelled?.params.requestId).toBe(listenId);
// No leaked state.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it('options.signal aborted while open: closes the subscription (notifications/cancelled sent)', async () => {
const { clientTx, written } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const ac = new AbortController();
const sub = await client.listen({ toolsListChanged: true }, { signal: ac.signal });
const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id;
written.length = 0;
ac.abort();
await flush();
expect(written).toHaveLength(1);
expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled');
expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(listenId);
// Caller-signal abort is consumer-initiated → 'local'.
await expect(sub.closed).resolves.toBe('local');
// close() after signal-abort is idempotent.
await sub.close();
expect(written).toHaveLength(1);
await client.close();
});
it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => {
const { clientTx } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
await client.close();
// listen() is async, so a pre-send guard throw is delivered as the
// returned promise's rejection (no ack timer started, no park state).
const pending = client.listen({ toolsListChanged: true });
const error = await pending.catch(e => e as SdkError);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.NotConnected);
});
it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter = configured ∩ server-advertised)', async () => {
const filters: unknown[] = [];
const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter));
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, prompts: { onChanged } } }
);
await client.connect(clientTx);
expect(filters).toEqual([{ toolsListChanged: true, promptsListChanged: true }]);
expect(client.autoOpenedSubscription).toBeDefined();
expect(client.autoOpenedSubscription!.honoredFilter).toEqual({ toolsListChanged: true, promptsListChanged: true });
await client.autoOpenedSubscription!.close();
await client.close();
});
it('autoOpenedSubscription is cleared on close() and on a fresh reconnect', async () => {
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } }
);
const { clientTx } = await scriptedModern();
await client.connect(clientTx);
expect(client.autoOpenedSubscription).toBeDefined();
await client.close();
// close() clears every per-connection field.
expect(client.autoOpenedSubscription).toBeUndefined();
expect(client.getServerCapabilities()).toBeUndefined();
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
});
it('auto-open filter is configured ∩ server-advertised; empty intersection skips auto-open', async () => {
const filters: unknown[] = [];
// scriptedModern advertises tools.listChanged + prompts.listChanged but NOT resources.
const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter));
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
// Configures tools + resources; server advertises tools + prompts.
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, resources: { onChanged } } }
);
await client.connect(clientTx);
// Intersection = tools only.
expect(filters).toEqual([{ toolsListChanged: true }]);
expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true });
await client.close();
// Empty intersection: configures resources only; server advertises tools+prompts.
const filters2: unknown[] = [];
const { clientTx: clientTx2 } = await scriptedModern((_id, filter) => filters2.push(filter));
const client2 = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { resources: { onChanged } } }
);
await client2.connect(clientTx2);
expect(filters2).toEqual([]);
expect(client2.autoOpenedSubscription).toBeUndefined();
await client2.close();
});
it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = m => {
const req = m as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 's', version: '1' }
}
});
}
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
// Server refuses listen (capacity guard / not supported).
void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } });
}
};
await serverTx.start();
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } }
);
const errors: Error[] = [];
client.onerror = e => errors.push(e);
// connect MUST resolve: the modern connection is usable without listen.
await client.connect(clientTx);
expect(client.autoOpenedSubscription).toBeUndefined();
expect(errors).toHaveLength(1);
expect((errors[0] as { code?: number }).code).toBe(-32_603);
await client.close();
});
it('a misconfigured listChanged handler surfaces via onerror and SKIPS auto-open (no wire write)', async () => {
// Regression: when handler registration threw (the soft-fail catch),
// the auto-open filter was still built from the same `effective`,
// opening a listen stream for types whose handler never registered —
// delivered notifications dropped on the floor while consuming a
// server slot. Now a registration failure skips auto-open entirely.
const { clientTx, written } = await scriptedModernNoAck();
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged, debounceMs: -1 } } }
);
const errors: Error[] = [];
client.onerror = e => errors.push(e);
// connect MUST resolve: the modern connection is usable without listen.
await client.connect(clientTx);
expect(errors).toHaveLength(1);
expect(errors[0]!.message).toContain('Invalid tools listChanged options');
// Auto-open SKIPPED: no listen request hit the wire, no subscription.
expect(client.autoOpenedSubscription).toBeUndefined();
expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false);
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it('connect-scoped signal does NOT bind to the auto-opened subscription lifetime', async () => {
// Regression: forwarding connect()'s full RequestOptions into the
// auto-open listen() call meant a connect-scoped signal — typically
// `AbortSignal.timeout(30_000)` for the handshake — was bound to the
// SUBSCRIPTION lifetime. When it fired after connect resolved, the
// auto-opened stream was silently torn down.
const { clientTx, written } = await scriptedModern();
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } }
);
const errors: Error[] = [];
client.onerror = e => errors.push(e);
const connectScoped = new AbortController();
await client.connect(clientTx, { signal: connectScoped.signal });
expect(client.autoOpenedSubscription).toBeDefined();
written.length = 0;
// The connect-scoped signal fires AFTER connect resolved (as a
// handshake `AbortSignal.timeout` would).
connectScoped.abort();
await flush();
// The auto-opened subscription is still live: no wire teardown
// (`notifications/cancelled`) was sent, and the per-listen state
// entry is still registered.
expect(written.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false);
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
expect(errors).toHaveLength(0);
await client.close();
});
it('connect-scoped signal aborted DURING the auto-open ack wait: connect rejects fast (no 60s hang)', async () => {
// Regression: forwarding only {timeout} into the auto-open listen()
// meant connect()'s signal could not cancel the in-connect ack wait —
// an aborted connect blocked here for the full ack timeout.
const { clientTx } = await scriptedModernNoAck();
const closeSpy = vi.spyOn(clientTx, 'close');
const onChanged = () => {};
const client = new Client(
{ name: 'c', version: '1' },
{ versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } }
);
const connectScoped = new AbortController();
const t0 = Date.now();
const pending = client.connect(clientTx, { signal: connectScoped.signal });
// discover resolves; connect is now awaiting the auto-open ack.
await flush();
connectScoped.abort(new Error('connect-abort'));
const error = await pending.catch(e => e as Error);
expect(error).toBeInstanceOf(Error);
expect(Date.now() - t0).toBeLessThan(1000);
// No leaked per-listen state on the aborted connect.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
// A connect() rejection MUST NOT leave a half-open connection: the
// transport was closed before rethrowing (b142b80ea regression assertion).
await flush();
expect(closeSpy).toHaveBeenCalled();
expect(client.transport).toBeUndefined();
await client.close();
});
it('server answers listen with a JSON-RPC RESULT during opening: rejects ConnectionClosed (graceful pre-ack close, not 60s)', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = m => {
const req = m as { id?: number | string; method?: string };
if (req.method === 'server/discover' && req.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: req.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 's', version: '1' }
}
});
}
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
// Server is shutting down: emits the SubscriptionsListenResult
// before ever sending the ack. The client treats receipt of
// any result for the listen id as the graceful-close signal.
void serverTx.send({ jsonrpc: '2.0', id: req.id, result: {} });
}
};
await serverTx.start();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const t0 = Date.now();
const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
expect((error as SdkError).message).toContain('closed the subscription gracefully before acknowledging');
expect(Date.now() - t0).toBeLessThan(1000);
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it("inbound SubscriptionsListenResult post-ack: closed resolves 'graceful'; subscription torn down", async () => {
let listenId!: number | string;
let send!: (m: JSONRPCMessage) => void;
const { clientTx } = await scriptedModern((id, _f, s) => {
listenId = id;
send = s;
});
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
// The spec's graceful-close signal: the server emits the empty
// subscriptions/listen response, then closes the stream.
send({
jsonrpc: '2.0',
id: listenId,
result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId } }
} as JSONRPCMessage);
await expect(sub.closed).resolves.toBe('graceful');
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it('transport closes BEFORE the ack: listen() rejects fast', async () => {
const { clientTx, serverTx } = await scriptedModernNoAck();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const t0 = Date.now();
const pending = client.listen({ toolsListChanged: true });
await flush();
// Server-side transport closes before ever acking → Client's
// `_onclose` override settles every per-listen state machine.
await serverTx.close();
const error = await pending.catch(e => e as Error);
expect(error).toBeInstanceOf(Error);
expect(Date.now() - t0).toBeLessThan(1000);
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
expect((client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers.size).toBe(0);
});
it("transport closes WHILE the subscription is open: closed resolves 'remote'; close() is a no-op", async () => {
const { clientTx, serverTx, written } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
await serverTx.close();
await expect(sub.closed).resolves.toBe('remote');
// Transport-close settled the per-listen machine; nothing leaks.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
// sub.close() after transport-close is a no-op (state already 'closed'):
// no notifications/cancelled lands on a future connection.
written.length = 0;
await sub.close();
expect(written.some(m => (m as { method?: string }).method === 'notifications/cancelled')).toBe(false);
});
it('concurrent listens are independent (each ack resolves its own promise; closing one leaves the other open)', async () => {
const ids: (number | string)[] = [];
const { clientTx, written } = await scriptedModern(id => ids.push(id));
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const [a, b] = await Promise.all([client.listen({ toolsListChanged: true }), client.listen({ promptsListChanged: true })]);
expect(a.honoredFilter).toEqual({ toolsListChanged: true });
expect(b.honoredFilter).toEqual({ promptsListChanged: true });
expect(ids).toHaveLength(2);
expect(ids[0]).not.toBe(ids[1]);
const listenState = (client as unknown as { _listenState: Map<unknown, unknown> })._listenState;
expect(listenState.size).toBe(2);
written.length = 0;
await a.close();
// Only `a`'s id is cancelled; `b` stays open.
expect(written).toHaveLength(1);
expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled');
expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(ids[0]);
expect(listenState.size).toBe(1);
await b.close();
expect(listenState.size).toBe(0);
await client.close();
});
it('after close(): nothing further dispatched into the per-listen machine; late ack passes through unconsumed', async () => {
let listenId!: number | string;
let send!: (m: JSONRPCMessage) => void;
const { clientTx } = await scriptedModern((id, _f, s) => {
listenId = id;
send = s;
});
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
await sub.close();
// The per-listen entry is gone; a late server-side ack and a late
// server-side cancel for this id are NOT consumed by the
// `_onnotification` override (no entry matches) and reach the
// fallback handler.
const fallback: string[] = [];
client.fallbackNotificationHandler = async n => {
fallback.push(n.method);
};
send({
jsonrpc: '2.0',
method: 'notifications/subscriptions/acknowledged',
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} }
});
send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification);
await flush();
expect(fallback).toContain('notifications/subscriptions/acknowledged');
// The state machine stayed closed throughout (no leak, no resurrection).
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it('an unmatched ack passes through to fallbackNotificationHandler (not silently swallowed)', async () => {
const { clientTx, serverTx } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
const fallback: string[] = [];
client.fallbackNotificationHandler = async n => {
fallback.push(n.method);
};
await client.connect(clientTx);
// One listen is active; a stray ack referencing a FOREIGN id must
// reach the fallback handler instead of being silently swallowed.
const sub = await client.listen({ toolsListChanged: true });
await serverTx.send({
jsonrpc: '2.0',
method: 'notifications/subscriptions/acknowledged',
params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'foreign-id' }, notifications: {} }
});
await flush();
expect(fallback).toEqual(['notifications/subscriptions/acknowledged']);
await sub.close();
await client.close();
});
it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => {
// Edge: prior transport never fires onclose; consumer calls connect()
// again. The in-flight listen() promise from the old connection must
// reject with a clear "client reconnected/closed" error rather than
// hang on the (now-discarded) ack timer.
const { clientTx } = await scriptedModernNoAck();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const pending = client.listen({ toolsListChanged: true });
await flush();
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
// Fresh connect on a new transport — _resetConnectionState runs.
const { clientTx: clientTx2 } = await scriptedModern();
await client.connect(clientTx2);
const error = await pending.catch(e => e as Error);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
expect((error as SdkError).message).toContain('reconnected or closed');
// No leaked per-listen state from the old connection.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});
it("the listen request id is a STRING on the wire ('listen:N'); cancel echoes it verbatim", async () => {
const { clientTx, written } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
const wireListen = written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as {
id: unknown;
params: { _meta?: Record<string, unknown> };
};
// String id from a Client-owned counter — JSON-RPC valid; spec
// subscriptionId is the request id verbatim; zero collision with
// Protocol's numeric counter.
expect(typeof wireListen.id).toBe('string');
expect(wireListen.id).toMatch(/^listen:\d+$/);
// The auto-envelope is on the wire too.
expect(wireListen.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN);
written.length = 0;
await sub.close();
const cancel = written[0] as unknown as { method: string; params: { requestId: unknown } };
expect(cancel.params.requestId).toBe(wireListen.id);
await client.close();
});
it("transport-level per-request stream end (onRequestStreamEnd) → closed resolves 'remote'", async () => {
// Mock a transport that captures the per-request `onRequestStreamEnd`
// callback and fires it after the ack — simulating a Streamable HTTP
// server closing the listen request's SSE stream.
const { clientTx, serverTx } = await scriptedModern();
let onStreamEnd: (() => void) | undefined;
const realSend = clientTx.send.bind(clientTx);
clientTx.send = (m, opts) => {
if ((m as { method?: string }).method === 'subscriptions/listen') {
onStreamEnd = (opts as { onRequestStreamEnd?: () => void } | undefined)?.onRequestStreamEnd;
}
return realSend(m, opts);
};
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const sub = await client.listen({ toolsListChanged: true });
expect(onStreamEnd).toBeDefined();
// Transport reports the per-request stream ended (server closed the
// SSE response, network dropped it, reconnection exhausted).
onStreamEnd!();
await expect(sub.closed).resolves.toBe('remote');
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
// close() after stream-end is a no-op (state already 'closed').
await sub.close();
await serverTx.close();
});
it('close() resets per-connection state even when transport.close() rejects', async () => {
const { clientTx } = await scriptedModern();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
clientTx.close = () => Promise.reject(new Error('close blew up'));
await expect(client.close()).rejects.toThrow('close blew up');
// Per-connection state was cleared regardless.
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
});
});
describe('_resetConnectionState() clears connection-scoped debounce timers (fake timers)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('a debounced listChanged callback armed on a closed connection never fires', async () => {
const { clientTx, serverTx } = await scriptedModernNoAck();
const calls: unknown[] = [];
const client = new Client(
{ name: 'c', version: '1' },
{
versionNegotiation: { mode: 'auto' },
listChanged: { tools: { onChanged: (e, items) => calls.push({ e, items }), autoRefresh: false, debounceMs: 100 } }
}
);
const connecting = client.connect(clientTx);
await vi.runAllTimersAsync();
await connecting;
// Arm the debounce timer for `tools` on the current connection.
await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' });
await vi.advanceTimersByTimeAsync(0);
expect((client as unknown as { _listChangedDebounceTimers: Map<unknown, unknown> })._listChangedDebounceTimers.size).toBe(1);
// close() → _resetConnectionState() must clear the armed timer so the
// callback for the dead connection never fires.
await client.close();
expect((client as unknown as { _listChangedDebounceTimers: Map<unknown, unknown> })._listChangedDebounceTimers.size).toBe(0);
await vi.advanceTimersByTimeAsync(200);
expect(calls).toEqual([]);
});
});
describe('Client.listen() — ack timeout (fake timers)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('ack timer firing rejects with RequestTimeout and tears the wire down', async () => {
const { clientTx, written } = await scriptedModernNoAck();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
const connecting = client.connect(clientTx);
await vi.runAllTimersAsync();
await connecting;
const pending = client.listen({ toolsListChanged: true }, { timeout: 1000 });
// Capture rejection to avoid an unhandled-rejection on the timer tick.
const settled = pending.catch(e => e as SdkError);
await vi.advanceTimersByTimeAsync(1000);
const error = await settled;
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
// wireTeardown sent notifications/cancelled referencing the listen id.
const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id;
const cancelled = written.find(m => (m as JSONRPCNotification).method === 'notifications/cancelled');
expect(cancelled).toMatchObject({ params: { requestId: listenId } });
// No leaked state.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
expect((client as unknown as { _responseHandlers: Map<unknown, unknown> })._responseHandlers.size).toBe(0);
// Restore real timers before close to avoid hanging on transport timers.
vi.useRealTimers();
await client.close();
});
});

View file

@ -0,0 +1,510 @@
/**
* SEP-2243 client-side `Mcp-Param-*` mirroring (protocol revision 2026-07-28).
*
* Covers: `tools/list` exclusion of constraint-violating definitions; per-call
* `Mcp-Param-*` header construction from the response-cache's `tools/list`
* entry and the `toolDefinition` escape hatch; era-parity (legacy `callTool`
* byte-untouched); stdio MAY-ignore (no headers on a single-channel
* transport); the one-evict-refetch-retry on `HEADER_MISMATCH`.
*/
import type { JSONRPCMessage, JSONRPCRequest, Tool, TransportSendOptions } from '@modelcontextprotocol/core-internal';
import {
encodeMcpParamValue,
HEADER_MISMATCH_ERROR_CODE,
InMemoryTransport,
PROTOCOL_VERSION_META_KEY
} from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { Client } from '../../src/client/client';
import { InMemoryResponseCacheStore, type ResponseCacheStore } from '../../src/client/responseCache';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
const MODERN = '2026-07-28';
/** Partition the `Client` derives for the scripted server (`serverInfo.name@version`, default `cachePartition`). */
const PART = JSON.stringify(['scripted@1.0.0', '']);
const REGION_TOOL: Tool = {
name: 'route',
inputSchema: {
type: 'object',
properties: { region: { type: 'string', 'x-mcp-header': 'Region' }, query: { type: 'string' } }
}
};
const INVALID_TOOL: Tool = {
name: 'broken',
inputSchema: { type: 'object', properties: { a: { type: 'object', 'x-mcp-header': 'Data' } } }
};
interface Scripted {
clientTx: InMemoryTransport;
serverTx: InMemoryTransport;
/** Headers passed via TransportSendOptions for each tools/call (undefined when none). */
callHeaders: Array<Record<string, string> | undefined>;
listCount: () => number;
}
async function scriptedModernServer(pages: Tool[][], rejectFirstCall = false): Promise<Scripted> {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const callHeaders: Array<Record<string, string> | undefined> = [];
let calls = 0;
let lists = 0;
// Tap the client→server channel to observe TransportSendOptions.headers
// (InMemoryTransport ignores it; this is the seam under test).
const realSend = clientTx.send.bind(clientTx);
clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise<void> => {
if ((m as JSONRPCRequest).method === 'tools/call') {
callHeaders.push(opts?.headers ? { ...opts.headers } : undefined);
}
return realSend(m, opts);
};
serverTx.onmessage = m => {
const r = m as JSONRPCRequest;
if (r.id === undefined) return;
if (r.method === 'server/discover') {
void serverTx.send({
jsonrpc: '2.0',
id: r.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'scripted', version: '1.0.0' }
}
});
} else if (r.method === 'tools/list') {
lists++;
const cursor = (r.params as { cursor?: string } | undefined)?.cursor;
const idx = cursor === undefined ? 0 : Number(cursor);
const next = idx + 1 < pages.length ? String(idx + 1) : undefined;
void serverTx.send({
jsonrpc: '2.0',
id: r.id,
result: {
resultType: 'complete',
ttlMs: 60_000,
cacheScope: 'public',
tools: pages[idx] ?? [],
...(next !== undefined && { nextCursor: next })
}
});
} else if (r.method === 'tools/call') {
calls++;
if (rejectFirstCall && calls === 1) {
void serverTx.send({
jsonrpc: '2.0',
id: r.id,
error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: the request headers and body disagree' }
});
} else {
void serverTx.send({
jsonrpc: '2.0',
id: r.id,
result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] }
});
}
}
};
await serverTx.start();
return { clientTx, serverTx, callHeaders, listCount: () => lists };
}
function modernClient(store?: InMemoryResponseCacheStore): Client {
return new Client(
{ name: 'param-mirror-client', version: '1.0.0' },
{ versionNegotiation: { mode: { pin: MODERN } }, ...(store && { responseCacheStore: store }) }
);
}
describe('SEP-2243 Mcp-Param-* mirroring (modern era)', () => {
it('listTools() and the cached tools/list entry exclude constraint-violating x-mcp-header tools and warn', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const store = new InMemoryResponseCacheStore();
const { clientTx } = await scriptedModernServer([[REGION_TOOL, INVALID_TOOL]]);
const client = modernClient(store);
await client.connect(clientTx);
// Auto-aggregate listTools() filters and writes the CACHED aggregate
// (the entry mirroring reads).
const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toEqual(['route']);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("excluding tool 'broken'"));
expect((store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools.map(t => t.name)).toEqual([
'route'
]);
// The explicit-cursor per-page path is filtered too (the spec's MUST
// has no carve-out for paginated reads).
const page = await client.listTools({ cursor: '0' });
expect(page.tools.map(t => t.name)).toEqual(['route']);
warn.mockRestore();
});
it('callTool() passes Mcp-Param-* via TransportSendOptions.headers from the cached tools/list entry; null/absent are omitted', async () => {
const { clientTx, callHeaders } = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient();
await client.connect(clientTx);
await client.listTools();
await client.callTool({ name: 'route', arguments: { region: 'us-west1', query: 'x' } });
await client.callTool({ name: 'route', arguments: { region: null, query: 'x' } as Record<string, unknown> });
expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' });
expect(callHeaders[1]).toBeUndefined();
});
it('callTool() uses the toolDefinition escape hatch without a prior tools/list', async () => {
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient();
await client.connect(clientTx);
await client.callTool({ name: 'route', arguments: { region: 'eu' } }, { toolDefinition: REGION_TOOL });
expect(listCount()).toBe(0);
expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'eu' });
});
it('callTool() evicts the tools/list entry, refetches once and retries on a HEADER_MISMATCH rejection (stale-cache path)', async () => {
const store = new InMemoryResponseCacheStore();
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]], /* rejectFirstCall */ true);
const client = modernClient(store);
await client.connect(clientTx);
// Seed a STALE entry at the connected-server partition (a STALE
// declaration on `region`) so callTool reads IT and the first send
// carries the stale `Mcp-Param-Stale-Region` header — server rejects
// HEADER_MISMATCH, client evicts, refetches via listTools()
// (the live REGION_TOOL), and retries with the correct header.
store.set(
{ method: 'tools/list', partition: PART },
{
value: {
tools: [
{
name: 'route',
inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } }
}
]
}
}
);
const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
expect(listCount()).toBe(1);
// First send mirrored the SEEDED stale declaration (proves the
// stale-cache read path, not cold-cache); retry mirrored the live one.
expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'ap' }, { 'Mcp-Param-Region': 'ap' }]);
// The recovery refetch wrote a fresh cache entry (REGION_TOOL, with the declaration).
expect(
(store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties
).toHaveProperty('region');
});
it("HEADER_MISMATCH recovery refetch reaches the wire even when the store's delete() no-ops (cacheMode:'refresh' bypasses the stale entry)", async () => {
// A custom store whose `delete()` is a no-op (or rejects) leaves the
// stale `tools/list` entry in place after `evict()`. The recovery
// refetch must NOT be cache-served that stale entry — it carries
// `cacheMode: 'refresh'` so it always reaches the wire and overwrites.
const store = new InMemoryResponseCacheStore();
(store as ResponseCacheStore).delete = () => undefined;
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]], /* rejectFirstCall */ true);
const client = modernClient(store);
await client.connect(clientTx);
// Seed a STALE-and-fresh entry (the declaration mirrors as
// `Stale-Region`; expiresAt in the future so a default-mode
// `listTools()` WOULD serve it if not for `'refresh'`).
store.set(
{ method: 'tools/list', partition: PART },
{
value: {
tools: [
{
name: 'route',
inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } }
}
]
},
expiresAt: Date.now() + 60_000,
scope: 'public'
}
);
const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
// The refetch hit the wire (delete() no-op did NOT short-circuit it
// into a cache serve of the stale seed).
expect(listCount()).toBe(1);
// Retry mirrored the LIVE declaration, not the stale seed.
expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'ap' }, { 'Mcp-Param-Region': 'ap' }]);
// The refetch's write overwrote the stale entry (the no-op delete
// never dropped it; the `'refresh'` write replaced it).
expect(
(store.get({ method: 'tools/list', partition: PART })?.value as { tools: Tool[] }).tools[0]?.inputSchema.properties
).toHaveProperty(['region', 'x-mcp-header'], 'Region');
});
it('callTool() with a cold cache issues NO tools/list and sends without Mcp-Param-* headers (cache reads only)', async () => {
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient();
await client.connect(clientTx);
const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
// No on-demand populate: callTool reads the cache directly. Cold ⇒
// proceed without headers (the spec's "client SHOULD send without
// custom headers" guidance) — the only callTool-driven tools/list is
// the HEADER_MISMATCH recovery path.
expect(listCount()).toBe(0);
expect(callHeaders).toEqual([undefined]);
});
it('a custom store whose get() rejects is routed to onerror and callTool degrades (no headers, no validation, result preserved)', async () => {
const store = new InMemoryResponseCacheStore();
(store as ResponseCacheStore).get = () => Promise.reject(new Error('redis down'));
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient(store);
const errors: Error[] = [];
client.onerror = e => errors.push(e);
await client.connect(clientTx);
// The pre-send mirroring read AND the post-success validator read both
// hit a rejecting `get()`. Neither aborts the call: the request goes
// out without `Mcp-Param-*` headers (cold-cache posture), the
// server-side result is returned, and both store failures surface via
// `onerror`. The post-success guard is the critical one — a store
// failure after the server has executed the call must never surface
// as a `callTool()` rejection (duplicate-execution hazard on retry).
const result = await client.callTool({ name: 'route', arguments: { region: 'ap' } });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
expect(callHeaders).toEqual([undefined]);
expect(listCount()).toBe(0);
expect(errors.map(e => e.message)).toEqual(['redis down', 'redis down']);
});
it('a paginating server: the cached aggregate holds every page and a page-2 x-mcp-header tool mirrors on the first call', async () => {
const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } };
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]]);
const client = modernClient();
await client.connect(clientTx);
const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toEqual(['echo', 'route']);
expect(listCount()).toBe(2);
await client.callTool({ name: 'route', arguments: { region: 'us-west1' } });
expect(callHeaders[0]).toEqual({ 'Mcp-Param-Region': 'us-west1' });
});
it('HEADER_MISMATCH recovery refetch walks every page; a page-2 x-mcp-header tool is recovered (stale-cache path)', async () => {
const PAGE1: Tool = { name: 'echo', inputSchema: { type: 'object', properties: {} } };
const store = new InMemoryResponseCacheStore();
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[PAGE1], [REGION_TOOL]], /* rejectFirstCall */ true);
const client = modernClient(store);
await client.connect(clientTx);
// Seed a STALE entry at the connected-server partition (one stale
// page; `route` carries a STALE declaration) so callTool reads it,
// mirrors the stale header on the first send, and the recovery
// refetch (via listTools()) then walks BOTH live pages.
store.set(
{ method: 'tools/list', partition: PART },
{
value: {
tools: [
PAGE1,
{
name: 'route',
inputSchema: { type: 'object', properties: { region: { type: 'string', 'x-mcp-header': 'Stale-Region' } } }
}
]
}
}
);
const result = await client.callTool({ name: 'route', arguments: { region: 'us-west1' } });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
// The recovery refetch walked both pages.
expect(listCount()).toBe(2);
// First send mirrored the SEEDED stale declaration (proves the
// stale-cache read path); retry mirrored the live page-2 declaration.
expect(callHeaders).toEqual([{ 'Mcp-Param-Stale-Region': 'us-west1' }, { 'Mcp-Param-Region': 'us-west1' }]);
// A follow-up call still mirrors from the cached entry (no extra list).
await client.callTool({ name: 'route', arguments: { region: 'eu' } });
expect(callHeaders[2]).toEqual({ 'Mcp-Param-Region': 'eu' });
expect(listCount()).toBe(2);
});
it('notifications/tools/list_changed evicts the cached entry; the next callTool reads cold (no auto-refetch)', async () => {
const store = new InMemoryResponseCacheStore();
const { clientTx, serverTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient(store);
await client.connect(clientTx);
// Seed a STALE entry at the connected-server partition; list_changed
// evicts it (partition-scoped delete); the next callTool reads cold
// and sends without headers — callTool never refetches on its own.
store.set(
{ method: 'tools/list', partition: PART },
{ value: { tools: [{ name: 'route', inputSchema: { type: 'object', properties: {} } }] } }
);
expect(store.get({ method: 'tools/list', partition: PART })).toBeDefined();
await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' } as JSONRPCMessage);
expect(store.get({ method: 'tools/list', partition: PART })).toBeUndefined();
const result = await client.callTool({ name: 'route', arguments: { region: 'us' } });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
expect(listCount()).toBe(0);
expect(callHeaders).toEqual([undefined]);
});
it('_resetConnectionState() clears the response cache (close → reconnect → no stale scan)', async () => {
const a = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient();
await client.connect(a.clientTx);
await client.listTools();
await client.close();
const b = await scriptedModernServer([[{ name: 'route', inputSchema: { type: 'object', properties: {} } }]]);
await client.connect(b.clientTx);
await client.callTool({ name: 'route', arguments: { region: 'us' } });
// The cache from A was cleared on close → callTool reads cold against
// server B → no Mcp-Param-* headers (no stale scan from A's entry),
// and no callTool-driven tools/list either.
expect(b.listCount()).toBe(0);
expect(b.callHeaders[0]).toBeUndefined();
});
});
describe('SEP-2243 era parity / stdio exemption', () => {
it('legacy-era callTool() is byte-untouched: zero tools/list requests, no headers, no exclusion', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const callHeaders: Array<Record<string, string> | undefined> = [];
const sentMethods: string[] = [];
const realSend = clientTx.send.bind(clientTx);
clientTx.send = (m: JSONRPCMessage, opts?: TransportSendOptions): Promise<void> => {
if ('method' in m) sentMethods.push((m as JSONRPCRequest).method);
if ((m as JSONRPCRequest).method === 'tools/call') callHeaders.push(opts?.headers ? { ...opts.headers } : undefined);
return realSend(m, opts);
};
serverTx.onmessage = m => {
const r = m as JSONRPCRequest;
if (r.id === undefined) return;
if (r.method === 'initialize') {
void serverTx.send({
jsonrpc: '2.0',
id: r.id,
result: { protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 's', version: '1' } }
});
} else if (r.method === 'tools/list') {
void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { tools: [REGION_TOOL, INVALID_TOOL] } });
} else if (r.method === 'tools/call') {
void serverTx.send({ jsonrpc: '2.0', id: r.id, result: { content: [{ type: 'text', text: 'ok' }] } });
}
};
await serverTx.start();
const client = new Client({ name: 'legacy', version: '1' });
await client.connect(clientTx);
expect(client.getProtocolEra()).toBe('legacy');
// PIN: a legacy/stdio callTool issues ZERO tools/list requests —
// callTool never auto-populates the cache; mirroring/validation read
// it directly (cold ⇒ skip).
await client.callTool({ name: 'route', arguments: { region: 'us' } });
expect(sentMethods.filter(m => m === 'tools/list')).toEqual([]);
expect(callHeaders).toEqual([undefined]);
const { tools } = await client.listTools();
// No exclusion on the legacy era — both tools present.
expect(tools.map(t => t.name)).toEqual(['route', 'broken']);
});
it('modern-era stdio callTool() issues zero tools/list requests (cold cache, mirroring inactive)', async () => {
// Mirrors the legacy pin above but on the modern era over a
// single-channel transport: even though `mirroringActive` is true,
// callTool reads the cache directly and sends nothing extra.
const { clientTx, callHeaders, listCount } = await scriptedModernServer([[REGION_TOOL]]);
const client = modernClient();
await client.connect(clientTx);
await client.callTool({ name: 'route', arguments: { region: 'us' } });
expect(listCount()).toBe(0);
expect(callHeaders).toEqual([undefined]);
});
it('stdio MAY-ignore: a single-channel transport drops TransportSendOptions.headers', async () => {
// InMemoryTransport stands in for stdio here: like the stdio transport
// it shares a single channel and ignores per-request HTTP headers.
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
let sawHeaders: unknown;
serverTx.onmessage = (_m, extra) => {
sawHeaders = (extra as { headers?: unknown } | undefined)?.headers;
};
await clientTx.start();
await (clientTx as { send: (m: JSONRPCMessage, opts?: TransportSendOptions) => Promise<void> }).send(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'x' } },
{ headers: { 'Mcp-Param-Region': 'us' } }
);
expect(sawHeaders).toBeUndefined();
});
});
describe('SEP-2243 Streamable HTTP transport seams', () => {
function transportWithCapture(): { tx: StreamableHTTPClientTransport; sent: () => Headers } {
let captured: Headers | undefined;
const fetch = vi.fn(async (_url, init) => {
captured = new Headers((init as RequestInit).headers);
return new Response(null, { status: 202, headers: { 'content-type': 'application/json' } });
});
const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch });
return { tx, sent: () => captured! };
}
const modernRequest = (method: string, params: Record<string, unknown>): JSONRPCMessage => ({
jsonrpc: '2.0',
id: 1,
method,
params: { ...params, _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN } }
});
it('Mcp-Name is sentinel-encoded for non-ASCII / unsafe values (no Headers.set TypeError)', async () => {
const { tx, sent } = transportWithCapture();
await tx.start();
await tx.send(modernRequest('resources/read', { uri: 'file:///レポート.md' }));
expect(sent().get('mcp-name')).toBe(encodeMcpParamValue('file:///レポート.md'));
// ASCII-safe values pass through unchanged.
await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }));
expect(sent().get('mcp-name')).toBe('route');
});
it('per-request TransportSendOptions.headers cannot override reserved standard/auth headers', async () => {
const { tx, sent } = transportWithCapture();
await tx.start();
await tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }), {
headers: { 'Mcp-Method': 'tools/list', authorization: 'Bearer evil', 'Mcp-Param-Region': 'us' }
});
expect(sent().get('mcp-method')).toBe('tools/call');
expect(sent().get('authorization')).toBeNull();
expect(sent().get('mcp-param-region')).toBe('us');
});
it('an HTTP 400 carrying a JSON-RPC error response is delivered in-band on a modern-enveloped request; legacy still throws SdkHttpError', async () => {
const errorBody = { jsonrpc: '2.0', id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE, message: 'Bad Request: …' } };
const fetch = vi.fn(
async () => new Response(JSON.stringify(errorBody), { status: 400, headers: { 'content-type': 'application/json' } })
);
const tx = new StreamableHTTPClientTransport(new URL('http://example.test/mcp'), { fetch: fetch as typeof globalThis.fetch });
const seen: JSONRPCMessage[] = [];
tx.onmessage = m => seen.push(m);
await tx.start();
await expect(tx.send(modernRequest('tools/call', { name: 'route', arguments: {} }))).resolves.toBeUndefined();
expect(seen[0]).toMatchObject({ id: 1, error: { code: HEADER_MISMATCH_ERROR_CODE } });
// Legacy-era exchange (no envelope claim) still surfaces 400 as the
// generic SdkHttpError — gating keeps the "legacy paths unchanged"
// claim true.
await expect(tx.send({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'route' } })).rejects.toMatchObject({
status: 400
});
});
});

View file

@ -0,0 +1,145 @@
/**
* TS-01 directionality, client side: the 2026-07-28 era has no server→client
* JSON-RPC request channel, and on stdio the client must never write JSON-RPC
* responses — so an inbound request arriving on a connection that negotiated
* a modern era is dropped (surfaced via `onerror`), never answered. Legacy-era
* connections keep today's behavior (the client answers, e.g. with −32601 for
* methods it has no handler for).
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
const flush = () => new Promise(resolve => setTimeout(resolve, 20));
/**
* A scripted server side of an in-memory pair: answers `server/discover` (so a
* negotiating client lands on the modern era) or `initialize` (legacy era), and
* records everything the client writes.
*/
async function scriptedServerSide(eras: 'modern' | 'legacy') {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const written: JSONRPCMessage[] = [];
serverTx.onmessage = message => {
written.push(message);
const request = message as { id?: number | string; method?: string };
if (request.method === 'server/discover' && request.id !== undefined) {
if (eras === 'modern') {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: {
resultType: 'complete',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'scripted-modern-server', version: '1.0.0' }
}
});
} else {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
error: { code: -32_601, message: 'Method not found' }
});
}
return;
}
if (request.method === 'initialize' && request.id !== undefined) {
void serverTx.send({
jsonrpc: '2.0',
id: request.id,
result: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' }
}
});
}
};
await serverTx.start();
return { clientTx, serverTx, written };
}
describe('client inbound-drop on modern-era connections (TS-01)', () => {
it('drops an inbound server→client request without writing any response, surfacing it via onerror', async () => {
const { clientTx, serverTx, written } = await scriptedServerSide('modern');
const client = new Client({ name: 'drop-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
const errors: Error[] = [];
client.onerror = error => void errors.push(error);
await client.connect(clientTx);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const before = written.length;
// A misbehaving "modern" server sends a server→client request (the
// channel is deleted in the 2026 era). The client must not answer.
await serverTx.send({
jsonrpc: '2.0',
id: 'rogue-1',
method: 'roots/list',
params: {}
});
await flush();
expect(written).toHaveLength(before);
expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true);
await client.close();
});
it('refuses a wire elicitation/create request on a modern connection even when an elicitation handler is registered (the in-band vocabulary grants no wire dispatch)', async () => {
const { clientTx, serverTx, written } = await scriptedServerSide('modern');
const client = new Client(
{ name: 'drop-client', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto' }, capabilities: { elicitation: { form: {} } } }
);
const handled: unknown[] = [];
client.setRequestHandler('elicitation/create', async request => {
handled.push(request.params);
return { action: 'accept', content: {} };
});
const errors: Error[] = [];
client.onerror = error => void errors.push(error);
await client.connect(clientTx);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const before = written.length;
// elicitation/create exists on the 2026-07-28 era only as in-band
// (embedded) vocabulary inside input_required results. A wire request
// for it must never reach the registered handler or be answered with a
// result — the era gate is not bypassed by the in-band schema fallback.
await serverTx.send({
jsonrpc: '2.0',
id: 'rogue-elicit-1',
method: 'elicitation/create',
params: { mode: 'form', message: 'Name?', requestedSchema: { type: 'object', properties: {} } }
});
await flush();
expect(handled).toHaveLength(0);
expect(written).toHaveLength(before);
expect(errors.some(error => error.message.includes('Dropped inbound request'))).toBe(true);
await client.close();
});
it('keeps answering inbound requests on legacy-era connections (control arm)', async () => {
const { clientTx, serverTx, written } = await scriptedServerSide('legacy');
const client = new Client({ name: 'legacy-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION);
await serverTx.send({ jsonrpc: '2.0', id: 'legacy-1', method: 'roots/list', params: {} });
await flush();
// Today's behavior: the client answers (here −32601, no roots handler installed).
const answer = written.find(message => (message as { id?: string }).id === 'legacy-1');
expect(answer).toBeDefined();
expect((answer as { error?: { code: number } }).error?.code).toBe(-32_601);
await client.close();
});
});

View file

@ -0,0 +1,313 @@
/**
* Row-by-row tests for the merged probe-outcome classifier table.
*
* Each `describe` block names the row of the adjudicated table it covers. The
* HTTP-shaped fixtures mirror the exact bodies deployed servers emit
* (`createJsonErrorResponse`: `{"jsonrpc":"2.0","error":{...},"id":null}`); the
* end-to-end capture of the same shapes from real server transports lives in
* test/integration/test/client/versionNegotiation.test.ts.
*/
import { SdkError, SdkErrorCode, UnsupportedProtocolVersionError } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier';
import { classifyProbeOutcome } from '../../src/client/probeClassifier';
const MODERN = '2026-07-28';
const LEGACY = '2025-11-25';
const baseContext: ProbeClassifierContext = {
clientModernVersions: [MODERN],
requestedVersion: MODERN,
fallbackAvailable: true,
environment: 'node',
transportKind: 'http'
};
function classify(outcome: ProbeOutcome, context: Partial<ProbeClassifierContext> = {}): ProbeVerdict {
return classifyProbeOutcome(outcome, { ...baseContext, ...context });
}
const discoverResult = (supportedVersions: string[]) => ({
supportedVersions,
capabilities: { tools: {} },
serverInfo: { name: 'fixture-server', version: '1.0.0' }
});
/** The deployed-fleet 400 body for a JSON-RPC error (server streamableHttp `createJsonErrorResponse`). */
const httpErrorBody = (code: number, message: string, data?: unknown) =>
JSON.stringify({ jsonrpc: '2.0', error: data === undefined ? { code, message } : { code, message, data }, id: null });
describe('row: DiscoverResult with version overlap → modern, select from supportedVersions', () => {
test('selects the mutual modern version', () => {
const verdict = classify({ kind: 'result', result: discoverResult([MODERN, '2027-01-01']) });
expect(verdict).toMatchObject({ kind: 'modern', version: MODERN });
});
test('selection follows the client preference order', () => {
const verdict = classify(
{ kind: 'result', result: discoverResult(['2027-01-01', MODERN]) },
{ clientModernVersions: ['2027-01-01', MODERN] }
);
expect(verdict).toMatchObject({ kind: 'modern', version: '2027-01-01' });
});
test('carries the parsed DiscoverResult for connection state', () => {
const verdict = classify({ kind: 'result', result: discoverResult([MODERN]) });
expect(verdict.kind).toBe('modern');
if (verdict.kind === 'modern') {
expect(verdict.discover.capabilities).toEqual({ tools: {} });
expect(verdict.discover.serverInfo.name).toBe('fixture-server');
}
});
});
describe('row: DiscoverResult with NO overlap → initialize on the same connection, else typed error with synthesized data', () => {
test('fallback possible → legacy (era selection on a dual-era server)', () => {
const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) });
expect(verdict).toEqual({ kind: 'legacy' });
});
test('fallback impossible → typed UnsupportedProtocolVersionError with synthesized data', () => {
const verdict = classify({ kind: 'result', result: discoverResult(['2027-12-31']) }, { fallbackAvailable: false });
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError);
const error = verdict.error as UnsupportedProtocolVersionError;
expect(error.supported).toEqual(['2027-12-31']);
expect(error.requested).toBe(MODERN);
}
});
});
describe('row: -32022 + valid data.supported with a mutual modern version → select-and-continue, MUST NOT fall back', () => {
test('in-band -32022 yields a corrective verdict (never legacy)', () => {
const verdict = classify({
kind: 'rpc-error',
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: [MODERN], requested: '2027-01-01' }
});
expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN });
});
test('HTTP 400-bodied -32022 yields the same corrective verdict', () => {
const verdict = classify({
kind: 'http-error',
status: 400,
body: httpErrorBody(-32_022, 'Unsupported protocol version', { supported: [MODERN], requested: MODERN })
});
expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN });
});
test('corrective even when the mutual version equals the just-rejected one (T2/A6 — caller runs it exactly once)', () => {
const verdict = classify({
kind: 'rpc-error',
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: [MODERN], requested: MODERN }
});
expect(verdict).toMatchObject({ kind: 'corrective', version: MODERN });
if (verdict.kind === 'corrective') {
expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError);
}
});
});
describe('row: -32022 with a disjoint-but-modern list → typed error, never initialize', () => {
test('no mutual modern version but the list is modern', () => {
const verdict = classify({
kind: 'rpc-error',
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: ['2027-12-31'], requested: MODERN }
});
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect(verdict.error).toBeInstanceOf(UnsupportedProtocolVersionError);
expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual(['2027-12-31']);
}
});
});
describe('row: -32022 with a legacy-only list → initialize; modern-only client → typed error carrying data.supported', () => {
test('legacy-only list with fallback available → legacy', () => {
const verdict = classify({
kind: 'rpc-error',
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: [LEGACY, '2025-06-18'] }
});
expect(verdict).toEqual({ kind: 'legacy' });
});
test('legacy-only list, modern-only client → typed error carrying data.supported', () => {
const verdict = classify(
{ kind: 'rpc-error', code: -32_022, message: 'Unsupported protocol version', data: { supported: [LEGACY] } },
{ fallbackAvailable: false }
);
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect((verdict.error as UnsupportedProtocolVersionError).supported).toEqual([LEGACY]);
expect((verdict.error as UnsupportedProtocolVersionError).requested).toBe(MODERN);
}
});
test('-32022 with malformed data (no valid supported list) → conservative legacy', () => {
expect(classify({ kind: 'rpc-error', code: -32_022, message: 'nope', data: { supported: 'not-a-list' } })).toEqual({
kind: 'legacy'
});
expect(classify({ kind: 'rpc-error', code: -32_022, message: 'nope' })).toEqual({ kind: 'legacy' });
});
});
describe('row: -32601 → legacy (never modern evidence on the probe, including 200-bodied errors)', () => {
test('in-band -32601 (stdio / 200-bodied HTTP)', () => {
expect(classify({ kind: 'rpc-error', code: -32_601, message: 'Method not found' })).toEqual({ kind: 'legacy' });
});
test('HTTP 404-bodied -32601', () => {
expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_601, 'Method not found') })).toEqual({
kind: 'legacy'
});
});
});
describe('row: 400 + -32000 "Unsupported protocol version" literal (deployed TS-SDK fleet, stateless) → legacy', () => {
test('the byte-real literal body', () => {
// Fixture mirrors server/streamableHttp.ts validateProtocolVersion — the
// Q10-L1 frozen literal, consumed here as a fixture only.
const body = httpErrorBody(
-32_000,
`Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)`
);
expect(classify({ kind: 'http-error', status: 400, body })).toEqual({ kind: 'legacy' });
});
});
describe('row: 400 + -32000 free-text (stateful session-required shapes) → legacy', () => {
test('"Server not initialized" (stateful first contact; session is checked before version)', () => {
expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(-32_000, 'Bad Request: Server not initialized') })).toEqual({
kind: 'legacy'
});
});
test('"Mcp-Session-Id header is required"', () => {
expect(
classify({
kind: 'http-error',
status: 400,
body: httpErrorBody(-32_000, 'Bad Request: Mcp-Session-Id header is required')
})
).toEqual({ kind: 'legacy' });
});
test('in-band -32000 free-text', () => {
expect(classify({ kind: 'rpc-error', code: -32_000, message: 'Server not initialized' })).toEqual({ kind: 'legacy' });
});
});
describe('row: plain-text/unparseable 400, code 0, empty body, 406, any unrecognized shape → legacy (conservative D4)', () => {
test('plain-text 400', () => {
expect(classify({ kind: 'http-error', status: 400, body: 'Bad Request' })).toEqual({ kind: 'legacy' });
});
test('JSON-RPC error with code 0', () => {
expect(classify({ kind: 'rpc-error', code: 0, message: 'weird' })).toEqual({ kind: 'legacy' });
expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(0, 'weird') })).toEqual({ kind: 'legacy' });
});
test('empty body', () => {
expect(classify({ kind: 'http-error', status: 400, body: '' })).toEqual({ kind: 'legacy' });
expect(classify({ kind: 'http-error', status: 400 })).toEqual({ kind: 'legacy' });
});
test('406 Not Acceptable', () => {
expect(classify({ kind: 'http-error', status: 406, body: 'Not Acceptable: Client must accept text/event-stream' })).toEqual({
kind: 'legacy'
});
});
test('unrecognized 200 result shape (era-ambiguous first-request processing)', () => {
expect(classify({ kind: 'result', result: { ok: true } })).toEqual({ kind: 'legacy' });
});
});
describe('row: -32001 / -32020 / -32021 are NEVER probe-recognized → fall into unrecognized → legacy', () => {
test('-32001 (session-404 overload on deployed servers — the SDK-conventional code, never probe evidence)', () => {
expect(classify({ kind: 'rpc-error', code: -32_001, message: 'Session not found' })).toEqual({ kind: 'legacy' });
expect(classify({ kind: 'http-error', status: 404, body: httpErrorBody(-32_001, 'Session not found') })).toEqual({
kind: 'legacy'
});
});
test('-32020 (the spec-assigned HeaderMismatch code is still never probe evidence)', () => {
expect(classify({ kind: 'rpc-error', code: -32_020, message: 'Header mismatch' })).toEqual({ kind: 'legacy' });
expect(classify({ kind: 'http-error', status: 400, body: httpErrorBody(-32_020, 'Header mismatch') })).toEqual({
kind: 'legacy'
});
});
test('-32021 with data is NOT modern evidence', () => {
expect(classify({ kind: 'rpc-error', code: -32_021, message: 'Capability required', data: { capability: 'sampling' } })).toEqual({
kind: 'legacy'
});
});
});
describe('row: network outage → typed connect error (Node)', () => {
test('connection refused is never an era verdict', () => {
const cause = Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' });
const verdict = classify({ kind: 'network-error', error: cause });
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect(verdict.error).toBeInstanceOf(SdkError);
expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
}
});
test('a Node TypeError (no CORS layer) is still a typed connect error', () => {
const verdict = classify({ kind: 'network-error', error: new TypeError('fetch failed') }, { environment: 'node' });
expect(verdict.kind).toBe('error');
});
});
describe('row: timeout — transport-aware verdict', () => {
// The specification's backward-compatibility rule for stdio: "any other
// error, or does not respond within a reasonable timeout: the server is
// legacy. Fall back to the initialize handshake." The versioning
// compatibility matrix draws the same line per transport: stdio probe
// times out → fall back to initialize; on HTTP the legacy signal is a 4xx
// without a recognized modern error body, so silence stays an outage.
test('HTTP: timeout maps to the standard RequestTimeout SdkError (silence on a deployed server is an outage)', () => {
const verdict = classify({ kind: 'timeout', timeoutMs: 60_000 }, { transportKind: 'http' });
expect(verdict.kind).toBe('error');
if (verdict.kind === 'error') {
expect(verdict.error).toBeInstanceOf(SdkError);
expect((verdict.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout);
}
});
test('stdio: timeout is a legacy-server signal → fall back to initialize on the same stream', () => {
expect(classify({ kind: 'timeout', timeoutMs: 5_000 }, { transportKind: 'stdio' })).toEqual({ kind: 'legacy' });
});
});
describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → legacy fallback (F-7)', () => {
test('browser environment + bare TypeError → legacy', () => {
expect(classify({ kind: 'network-error', error: new TypeError('Failed to fetch') }, { environment: 'browser' })).toEqual({
kind: 'legacy'
});
});
test('cross-realm TypeError (name-based recognition) → legacy in a browser', () => {
const foreign = new Error('Failed to fetch');
foreign.name = 'TypeError';
expect(classify({ kind: 'network-error', error: foreign }, { environment: 'browser' })).toEqual({ kind: 'legacy' });
});
test('browser non-TypeError network failure stays a typed connect error', () => {
const verdict = classify({ kind: 'network-error', error: new Error('socket hang up') }, { environment: 'browser' });
expect(verdict.kind).toBe('error');
});
});

View file

@ -0,0 +1,231 @@
/**
* Merged first-contact fixture corpus (T9 probe edges ∪ wire-real shapes)
* binding the two pure modules of the negotiation path:
*
* - the probe-outcome classifier (`classifyProbeOutcome`): the five T9 probe
* edges (plain-text 400; JSON-RPC `code: 0`; probe-success-then-no-overlap
* → initialize on the SAME connection; legacy servers that 200-process
* era-ambiguous first requests; numeric-id collision avoidance via a string
* probe id) merged with the wire-real first-contact shapes a deployed 2025
* TypeScript server actually answers (the −32000 "Unsupported protocol
* version" literal and the 400/−32000 session-required body). Recognition
* is a typed allowlist — codes and structured data — never message-text
* sniffing.
* - the server-side opening classification (the era a connection's first
* exchange selects) is bound by `packages/server/test/server/serveStdio.test.ts`.
*
* Probe RUNTIME (timeout/retry policy and the connect loop) is covered by the
* negotiation engine suites; this corpus pins classification only, plus the
* probe wire shape (string id, `server/discover` first, never a real request).
*/
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Client } from '../../src/client/client';
import type { ProbeClassifierContext, ProbeOutcome, ProbeVerdict } from '../../src/client/probeClassifier';
import { classifyProbeOutcome } from '../../src/client/probeClassifier';
const MODERN = '2026-07-28';
const baseContext: ProbeClassifierContext = {
clientModernVersions: [MODERN],
requestedVersion: MODERN,
fallbackAvailable: true,
environment: 'node',
transportKind: 'stdio'
};
/** The byte-exact first-contact literal a deployed 2025 stateless server answers a modern probe with. */
const DEPLOYED_UNSUPPORTED_VERSION_BODY = JSON.stringify({
jsonrpc: '2.0',
id: null,
error: {
code: -32_000,
message: `Bad Request: Unsupported protocol version: ${MODERN} (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)`
}
});
/** The session-required free-text shape a deployed stateful server answers a session-less probe with. */
const DEPLOYED_SESSION_REQUIRED_BODY = JSON.stringify({
jsonrpc: '2.0',
id: null,
error: { code: -32_000, message: 'Bad Request: Server not initialized' }
});
interface CorpusRow {
name: string;
outcome: ProbeOutcome;
context?: Partial<ProbeClassifierContext>;
expected: ProbeVerdict['kind'];
}
const CORPUS: CorpusRow[] = [
// --- T9 edge 1: plain-text 400 (no JSON-RPC body at all).
{
name: 'T9: plain-text HTTP 400 → legacy fallback',
outcome: { kind: 'http-error', status: 400, body: 'Bad Request' },
expected: 'legacy'
},
// --- T9 edge 2: JSON-RPC error with code 0.
{
name: 'T9: JSON-RPC error code 0 → legacy fallback',
outcome: { kind: 'rpc-error', code: 0, message: 'unknown method' },
expected: 'legacy'
},
// --- T9 edge 3: probe success but no version overlap → initialize on the SAME connection.
{
name: 'T9: DiscoverResult with no mutual version + fallback available → legacy (initialize on the same connection)',
outcome: {
kind: 'result',
result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } }
},
expected: 'legacy'
},
{
name: 'T9: DiscoverResult with no mutual version + NO fallback (pin / modern-only) → typed error, never initialize',
outcome: {
kind: 'result',
result: { supportedVersions: ['2027-01-01'], capabilities: {}, serverInfo: { name: 's', version: '1' } }
},
context: { fallbackAvailable: false },
expected: 'error'
},
// --- T9 edge 4: a legacy server that 200-processes an era-ambiguous first request.
// The probe is server/discover precisely so this comes back as an
// unrecognized result shape (never a DiscoverResult) and stays legacy.
{
name: 'T9: 200-processed era-ambiguous result (not a DiscoverResult) → legacy fallback',
outcome: { kind: 'result', result: { tools: [{ name: 'echo', inputSchema: { type: 'object' } }] } },
expected: 'legacy'
},
// --- Wire-real shape A: the deployed −32000 unsupported-protocol-version literal (HTTP 400).
{
name: 'wire-real: HTTP 400 with the deployed -32000 "Unsupported protocol version" literal → legacy fallback',
outcome: { kind: 'http-error', status: 400, body: DEPLOYED_UNSUPPORTED_VERSION_BODY },
expected: 'legacy'
},
// --- Wire-real shape B: the deployed 400/−32000 session-required free text.
{
name: 'wire-real: HTTP 400 with the deployed -32000 session-required body → legacy fallback',
outcome: { kind: 'http-error', status: 400, body: DEPLOYED_SESSION_REQUIRED_BODY },
expected: 'legacy'
},
// --- Typed-recognizer allowlist: text never upgrades, codes + structured data decide.
{
name: 'recognizer: -32601 whose message merely CONTAINS "Unsupported protocol version" is not modern evidence → legacy',
outcome: { kind: 'rpc-error', code: -32_601, message: `Unsupported protocol version: ${MODERN}` },
expected: 'legacy'
},
{
name: 'recognizer: -32022 with a structured supported list naming a mutual modern version → corrective continuation',
outcome: {
kind: 'rpc-error',
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: [MODERN, LATEST_PROTOCOL_VERSION], requested: '2027-01-01' }
},
expected: 'corrective'
},
{
name: 'recognizer: -32022 without a parsable data.supported list is not actionable modern evidence → legacy',
outcome: { kind: 'rpc-error', code: -32_022, message: 'Unsupported protocol version' },
expected: 'legacy'
},
{
name: 'recognizer: -32022 with a legacy-only supported list is a definitive legacy signal → legacy',
outcome: {
kind: 'rpc-error',
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: [LATEST_PROTOCOL_VERSION], requested: MODERN }
},
expected: 'legacy'
},
{
name: 'recognizer: a 200 result that merely mentions supportedVersions in a text field is not a DiscoverResult → legacy',
outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } },
expected: 'legacy'
},
// --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error).
{
name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)',
outcome: { kind: 'timeout', timeoutMs: 500 },
expected: 'legacy'
},
{
name: 'timeout on HTTP → typed connect error, never an era verdict',
outcome: { kind: 'timeout', timeoutMs: 500 },
context: { transportKind: 'http' },
expected: 'error'
},
// --- -32601 from a deployed legacy server (the common pre-initialize answer).
{
name: 'wire-real: -32601 method-not-found → legacy fallback',
outcome: { kind: 'rpc-error', code: -32_601, message: 'Method not found' },
expected: 'legacy'
}
];
describe('T9/T11 merged probe fixture corpus (probe classifier)', () => {
for (const row of CORPUS) {
it(row.name, () => {
const verdict = classifyProbeOutcome(row.outcome, { ...baseContext, ...row.context });
expect(verdict.kind).toBe(row.expected);
});
}
it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => {
const verdict = classifyProbeOutcome(
{
kind: 'result',
result: { supportedVersions: [MODERN], capabilities: {}, serverInfo: { name: 's', version: '1' } }
},
baseContext
);
expect(verdict.kind).toBe('modern');
if (verdict.kind === 'modern') {
expect(verdict.version).toBe(MODERN);
}
});
});
describe('T9 edge 5: probe wire shape (string probe id on the shared pipe)', () => {
it('probes with server/discover before any real request, using a string request id and the protocol-version envelope key', async () => {
const written: JSONRPCMessage[] = [];
// A scripted silent-legacy transport: records what the client writes and
// never answers, so only the probe (and, after its timeout, the
// initialize fallback) ever reaches the wire.
const transport: Transport = {
async start() {},
async close() {},
async send(message) {
written.push(message);
}
};
const client = new Client(
{ name: 'probe-shape-client', version: '1.0.0' },
{ versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } }
);
// The silent transport also never answers initialize; the connect
// attempt eventually fails — the probe wire shape is what this pin is
// about.
await client.connect(transport, { timeout: 200 }).catch(() => {});
expect(written.length).toBeGreaterThan(0);
const probe = written[0] as { id?: unknown; method?: string; params?: { _meta?: Record<string, unknown> } };
expect(probe.method).toBe('server/discover');
// String probe id: the probe runs above the Protocol layer on the same
// shared pipe, so it must never collide with the numeric ids Protocol
// assigns to real requests.
expect(typeof probe.id).toBe('string');
expect(probe.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN);
// Never probe with the first real request: nothing other than the probe
// and the legacy initialize fallback is written during connect.
for (const message of written) {
const method = (message as { method?: string }).method;
expect(['server/discover', 'initialize', 'notifications/initialized']).toContain(method);
}
});
});

File diff suppressed because it is too large Load diff

View file

@ -45,12 +45,14 @@ describe('SSEClientTransport', () => {
res.writeHead(200, {
'Content-Type': 'application/json'
});
// RFC 8414 §3.3: issuer must match the URL the metadata was fetched from.
const self = `http://${req.headers.host}`;
res.end(
JSON.stringify({
issuer: 'https://auth.example.com',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
registration_endpoint: 'https://auth.example.com/register',
issuer: self,
authorization_endpoint: `${self}/authorize`,
token_endpoint: `${self}/token`,
registration_endpoint: `${self}/register`,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
@ -778,11 +780,14 @@ describe('SSEClientTransport', () => {
await transport.start();
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token'
});
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token'
}),
expect.anything()
);
expect(connectionAttempts).toBe(1);
expect(lastServerRequest.headers.authorization).toBe('Bearer new-token');
});
@ -931,11 +936,14 @@ describe('SSEClientTransport', () => {
await transport.send(message);
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token'
});
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token'
}),
expect.anything()
);
expect(postAttempts).toBe(1);
expect(lastServerRequest.headers.authorization).toBe('Bearer new-token');
});
@ -1018,7 +1026,7 @@ describe('SSEClientTransport', () => {
expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled();
});
it('invalidates all credentials on OAuthErrorCode.InvalidClient during token refresh', async () => {
it('invalidates client+tokens (not discovery) on OAuthErrorCode.InvalidClient during token refresh', async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'expired-token',
@ -1067,10 +1075,14 @@ describe('SSEClientTransport', () => {
});
await expect(() => transport.start()).rejects.toMatchObject(expectedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
// SEP-2352: 'client'+'tokens' (not 'all') so discoveryState survives for the
// callback-leg gate on retry.
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('client');
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
expect(mockAuthProvider.invalidateCredentials).not.toHaveBeenCalledWith('all');
});
it('invalidates all credentials on OAuthErrorCode.UnauthorizedClient during token refresh', async () => {
it('invalidates client+tokens (not discovery) on OAuthErrorCode.UnauthorizedClient during token refresh', async () => {
// Mock tokens() to return token with refresh token
mockAuthProvider.tokens.mockResolvedValue({
access_token: 'expired-token',
@ -1118,7 +1130,11 @@ describe('SSEClientTransport', () => {
});
await expect(() => transport.start()).rejects.toMatchObject(expectedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all');
// SEP-2352: 'client'+'tokens' (not 'all') so discoveryState survives for the
// callback-leg gate on retry.
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('client');
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
expect(mockAuthProvider.invalidateCredentials).not.toHaveBeenCalledWith('all');
});
it('invalidates tokens on OAuthErrorCode.InvalidGrant during token refresh', async () => {
@ -1517,12 +1533,15 @@ describe('SSEClientTransport', () => {
expect(tokenCalls.length).toBeGreaterThan(0);
// Verify tokens were saved
expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
});
expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
}),
expect.anything()
);
// Global fetch should never have been called
expect(globalFetchSpy).not.toHaveBeenCalled();

View file

@ -0,0 +1,69 @@
/**
* Behavior-surface pins: the stdio environment-inheritance safelist.
*
* getDefaultEnvironment() decides which parent environment variables every
* spawned stdio server inherits. Widening the safelist leaks more of the
* parent environment into child processes, so both the list itself and the
* filtering behavior are pinned. A failing pin here means the change is
* deliberate: update the pin in the same change, together with a changeset
* and a migration-doc entry.
*
* See docs/behavior-surface-pins.md for the maintenance protocol.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from '../../src/client/stdio';
// Frozen copy of the documented safelist. The expectation side is a literal,
// not derived from src, so any edit to DEFAULT_INHERITED_ENV_VARS goes red
// here regardless of which variables happen to be set in the runner's
// environment. (The behavioral test below cannot catch a widened safelist on
// its own: getDefaultEnvironment skips unset keys, and sensitive variables
// are exactly the ones typically unset in CI.)
const SAFELIST =
process.platform === 'win32'
? [
'APPDATA',
'HOMEDRIVE',
'HOMEPATH',
'LOCALAPPDATA',
'PATH',
'PROCESSOR_ARCHITECTURE',
'SYSTEMDRIVE',
'SYSTEMROOT',
'TEMP',
'USERNAME',
'USERPROFILE',
'PROGRAMFILES'
]
: ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
describe('stdio environment safelist', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test('DEFAULT_INHERITED_ENV_VARS matches the frozen safelist exactly', () => {
expect([...DEFAULT_INHERITED_ENV_VARS].sort()).toEqual([...SAFELIST].sort());
});
test('getDefaultEnvironment inherits exactly the safelist keys that are set', () => {
for (const key of SAFELIST) {
vi.stubEnv(key, `safe-${key}`);
}
vi.stubEnv('STDIO_PIN_SECRET', 'must-not-be-inherited');
const env = getDefaultEnvironment();
expect(Object.keys(env).sort()).toEqual([...SAFELIST].sort());
for (const key of SAFELIST) {
expect(env[key]).toBe(`safe-${key}`);
}
});
test('skips values that look like exported shell functions', () => {
vi.stubEnv('PATH', '() { echo pwned; }');
const env = getDefaultEnvironment();
expect(env.PATH).toBeUndefined();
});
});

View file

@ -404,6 +404,15 @@ describe('StreamableHTTPClientTransport', () => {
).toBe(true);
});
it('declares hasPerRequestStream so the protocol layer routes 2026-era cancellation to stream-close', () => {
// Spec basic/patterns/cancellation §Transport-Specific (2026-07-28):
// closing the per-request SSE stream IS the cancel signal on
// Streamable HTTP. Protocol.request() keys on this flag (plus the
// negotiated era) to abort `requestSignal` instead of POSTing
// `notifications/cancelled`.
expect(transport.hasPerRequestStream).toBe(true);
});
it('should support custom reconnection options', () => {
// Create a transport with custom reconnection options
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
@ -875,11 +884,14 @@ describe('StreamableHTTPClientTransport', () => {
expect(new Headers(secondPost[1]?.headers).get('authorization')).toBe('Bearer new-access-token');
expect(firstPost[1]?.body).toBe(JSON.stringify(message));
expect(secondPost[1]?.body).toBe(JSON.stringify(message));
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token'
});
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new-access-token',
token_type: 'Bearer',
refresh_token: 'new-refresh-token'
}),
expect.anything()
);
expect(mockAuthProvider.redirectToAuthorization).not.toHaveBeenCalled();
});
@ -921,11 +933,14 @@ describe('StreamableHTTPClientTransport', () => {
// Verify fetch was called twice
expect(fetchMock).toHaveBeenCalledTimes(2);
// Verify auth was called with the new scope
// Verify auth was called with the union scope (no prior scope → just the
// challenged scope) and forced fresh authorization (no prior token scope
// means the union is a strict superset of the empty grant).
expect(authSpy).toHaveBeenCalledWith(
mockAuthProvider,
expect.objectContaining({
scope: 'new_scope',
forceReauthorization: true,
resourceMetadataUrl: new URL('http://example.com/resource')
})
);
@ -933,7 +948,7 @@ describe('StreamableHTTPClientTransport', () => {
authSpy.mockRestore();
});
it('prevents infinite upscoping on repeated 403', async () => {
it('caps step-up retries per send (bounded counter)', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
@ -958,19 +973,94 @@ describe('StreamableHTTPClientTransport', () => {
const authSpy = vi.spyOn(authModule as typeof import('../../src/client/auth'), 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
// First send: should trigger upscoping
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
// First send: one step-up retry (default cap = 1), then fails.
await expect(transport.send(message)).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/);
expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth
expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once
// Second send: should fail immediately without re-calling auth
// Second send: counter is per-send-chain, not transport-wide — a fresh
// send tries step-up once again (cross-request tracking is host
// responsibility).
fetchMock.mockClear();
authSpy.mockClear();
await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping');
await expect(transport.send(message)).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/);
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call
expect(authSpy).not.toHaveBeenCalled(); // Auth not called again
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(authSpy).toHaveBeenCalledTimes(1);
authSpy.mockRestore();
});
it('step-up scope is the union of transport-tracked, token-granted, and challenged scopes', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
authProvider: mockAuthProvider,
maxStepUpRetries: 2
});
mockAuthProvider.tokens.mockResolvedValue({ access_token: 't', token_type: 'Bearer', scope: 'a b' });
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' };
const fetchMock = globalThis.fetch as Mock;
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="b c"' }),
text: () => Promise.resolve('')
})
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({ 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="d"' }),
text: () => Promise.resolve('')
})
.mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() });
const authModule = await import('../../src/client/auth');
const authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
await transport.send(message);
expect(authSpy).toHaveBeenCalledTimes(2);
// First step-up: union(undefined, token 'a b', challenge 'b c') = 'a b c'
expect(authSpy.mock.calls[0]![1].scope?.split(' ').sort()).toEqual(['a', 'b', 'c']);
// Second step-up: union(tracked 'a b c', token 'a b', challenge 'd') = 'a b c d'
expect(authSpy.mock.calls[1]![1].scope?.split(' ').sort()).toEqual(['a', 'b', 'c', 'd']);
authSpy.mockRestore();
});
it("throws InsufficientScopeError on 403 when onInsufficientScope is 'throw'", async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
authProvider: mockAuthProvider,
onInsufficientScope: 'throw'
});
const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' };
(globalThis.fetch as Mock).mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write", error_description="needs write"'
}),
text: () => Promise.resolve('Insufficient scope')
});
const authModule = await import('../../src/client/auth');
const authSpy = vi.spyOn(authModule, 'auth');
const { InsufficientScopeError } = await import('../../src/client/authErrors');
const sendPromise = transport.send(message);
await expect(sendPromise).rejects.toBeInstanceOf(InsufficientScopeError);
await expect(sendPromise).rejects.toMatchObject({
requiredScope: 'files:write',
errorDescription: 'needs write'
});
expect(authSpy).not.toHaveBeenCalled();
authSpy.mockRestore();
});
@ -1201,6 +1291,407 @@ describe('StreamableHTTPClientTransport', () => {
expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST');
});
it('per-request requestSignal abort: no onerror, no reconnect (McpSubscription.close())', async () => {
// ARRANGE — a POST stream that has been primed with an SSE event id
// (server-side resumability), so without the per-request abort
// guard the transport WOULD schedule a GET+Last-Event-ID reconnect.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 10,
maxRetries: 1,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const primedStream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
// Priming event with an id — would arm POST-stream resumability.
controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n'));
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
// Propagate abort to the stream the way fetch does.
init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true });
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: primedStream
});
});
const requestAbort = new AbortController();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} },
{ requestSignal: requestAbort.signal }
);
await vi.advanceTimersByTimeAsync(5);
expect(fetchMock).toHaveBeenCalledTimes(1);
// ACT — McpSubscription.close() aborts the per-request signal.
requestAbort.abort();
await vi.advanceTimersByTimeAsync(50);
// ASSERT — intentional per-request abort: no onerror, no reconnect.
expect(errorSpy).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('onRequestStreamEnd fires when the per-request POST stream ends gracefully without reconnecting', async () => {
// ARRANGE — a POST stream with NO priming event id (so the
// graceful-close path does NOT schedule a reconnect): the
// per-request stream simply ends.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const unprimedStream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
// An ack frame with no SSE event id — does NOT arm POST-stream resumability.
controller.enqueue(
new TextEncoder().encode(
'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n'
)
);
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: unprimedStream
})
);
const requestAbort = new AbortController();
const onStreamEnd = vi.fn();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} },
{ requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd }
);
await vi.advanceTimersByTimeAsync(5);
expect(onStreamEnd).not.toHaveBeenCalled();
// ACT — server gracefully closes the SSE stream.
streamController.close();
await vi.advanceTimersByTimeAsync(5);
// ASSERT — non-deliberate stream end without reconnecting:
// onRequestStreamEnd fired exactly once; no further fetches.
expect(onStreamEnd).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('onRequestStreamEnd does NOT fire on a deliberate per-request abort', async () => {
// Same shape as the no-onerror/no-reconnect test, but assert the
// stream-end callback is NEVER invoked when `requestSignal` was the
// abort source.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true });
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});
});
const requestAbort = new AbortController();
const onStreamEnd = vi.fn();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} },
{ requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd }
);
await vi.advanceTimersByTimeAsync(5);
// ACT — deliberate per-request abort.
requestAbort.abort();
await vi.advanceTimersByTimeAsync(50);
// ASSERT — deliberate abort: onRequestStreamEnd never fires.
expect(onStreamEnd).not.toHaveBeenCalled();
});
it('onRequestStreamEnd fires when reconnection attempts are exhausted (maxRetries reached)', async () => {
// ARRANGE — a primed POST stream (so a non-deliberate close
// schedules a GET resume); every GET resume fails; maxRetries 1
// means the second schedule hits the exhausted branch.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 5,
maxRetries: 1,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
const errorSpy = vi.fn();
transport.onerror = errorSpy;
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const primedStream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n'));
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: primedStream
});
// The GET resume fails with a 5xx → reconnect catch reschedules → exhausted.
fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'unavailable', headers: new Headers() });
const onStreamEnd = vi.fn();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} },
{ requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd }
);
await vi.advanceTimersByTimeAsync(5);
expect(onStreamEnd).not.toHaveBeenCalled();
// ACT — server closes the primed POST stream non-deliberately.
streamController.close();
await vi.advanceTimersByTimeAsync(100);
// ASSERT — exhausted: onRequestStreamEnd fired exactly once (the
// max-retries branch); the exhausted onerror surfaced.
expect(onStreamEnd).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Maximum reconnection attempts') })
);
});
it('onRequestStreamEnd fires when the per-request POST stream ERRORS without reconnecting', async () => {
// ARRANGE — a POST stream with NO priming event id; the body
// errors (network drop). The error-branch `else` (no reconnect,
// not intentional-abort) must fire onRequestStreamEnd.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
const failingStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n'
)
);
queueMicrotask(() => controller.error(new Error('network drop')));
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: failingStream
});
const onStreamEnd = vi.fn();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} },
{ requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd }
);
await vi.advanceTimersByTimeAsync(50);
// ASSERT — error-branch fired exactly once; no reconnection
// attempted (POST stream wasn't primed).
expect(onStreamEnd).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('onRequestStreamEnd does NOT fire on transport.close()', async () => {
// The transport-wide abort is the OTHER deliberate teardown
// (`isIntentionalAbort()` checks both signals): a per-request
// stream-end callback must not fire when close() tore the stream
// down — `_onclose` is the settle path for that.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n'));
}
});
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true });
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: stream
});
});
const onStreamEnd = vi.fn();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} },
{ requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd }
);
await vi.advanceTimersByTimeAsync(5);
// ACT — transport-wide close.
await transport.close();
await vi.advanceTimersByTimeAsync(50);
// ASSERT — deliberate transport close: onRequestStreamEnd never fires.
expect(onStreamEnd).not.toHaveBeenCalled();
});
it('onRequestStreamEnd fires when a primed POST→GET resume hits 405 (non-resumable terminal)', async () => {
// R1 regression: against a server that stamps SSE event ids on the
// listen POST stream but returns 405 on the GET resume,
// `_startOrAuthSse` resolved without a stream and nothing fired —
// the subscription dead-ended silently. The 405 is now a terminal
// per-request stream-end. ALSO asserts the GET resume carried the
// per-request `requestSignal` (the close-after-reconnect path).
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions: {
initialReconnectionDelay: 5,
maxRetries: 3,
maxReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1
}
});
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const primedStream = new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n'));
}
});
const fetchMock = globalThis.fetch as Mock;
let getSignal: AbortSignal | null | undefined;
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'text/event-stream' }),
body: primedStream
});
fetchMock.mockImplementationOnce((_url, init: RequestInit) => {
getSignal = init.signal;
return Promise.resolve({ ok: false, status: 405, headers: new Headers() });
});
const requestAbort = new AbortController();
const onStreamEnd = vi.fn();
await transport.start();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} },
{ requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd }
);
await vi.advanceTimersByTimeAsync(5);
// ACT — server closes the primed POST stream → schedules a GET resume → 405.
streamController.close();
await vi.advanceTimersByTimeAsync(50);
// ASSERT — onRequestStreamEnd fired exactly once on the 405; the
// resume was a single GET (no further retries — 405 resolves).
expect(onStreamEnd).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET');
// requestSignal threaded through the GET reconnect: aborting the
// per-request signal aborts the resume's fetch signal.
expect(getSignal).toBeDefined();
expect(getSignal?.aborted).toBe(false);
requestAbort.abort();
expect(getSignal?.aborted).toBe(true);
});
it('per-request requestSignal abort BEFORE response headers: no misleading onerror; send() still rejects', async () => {
// ARRANGE — fetch is in flight (pending promise) when the
// requestSignal aborts; fetch rejects with AbortError before the
// SSE stream handler ever runs. _send's catch must apply the same
// intentional-abort guard as _handleSseStream.
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
const errorSpy = vi.fn();
transport.onerror = errorSpy;
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockImplementationOnce(
(_url, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true });
})
);
const requestAbort = new AbortController();
await transport.start();
const sent = transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} },
{ requestSignal: requestAbort.signal }
);
// Let _send reach the in-flight fetch.
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
// ACT — abort before headers.
requestAbort.abort(new Error('intentional'));
// ASSERT — send() rejects (so listen()'s send-catch settles), but no onerror.
await expect(sent).rejects.toThrow();
expect(errorSpy).not.toHaveBeenCalled();
});
it('anySignal fallback removes the sibling listener (no leak on the transport-lifetime signal)', async () => {
// ARRANGE — force the manual fallback path (Node 20.0–20.2).
const nativeAny = AbortSignal.any;
(AbortSignal as { any?: unknown }).any = undefined;
try {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValue({ ok: true, status: 202, headers: new Headers() });
await transport.start();
const transportSignal = (transport as unknown as { _abortController: AbortController })._abortController.signal;
const addSpy = vi.spyOn(transportSignal, 'addEventListener');
const removeSpy = vi.spyOn(transportSignal, 'removeEventListener');
// ACT — N sends each with a fresh request-scoped signal that
// aborts after the send completes (the McpSubscription.close()
// pattern). Each send registers one fallback listener on the
// transport-lifetime signal; aborting the request-scoped
// signal must remove it.
for (let i = 0; i < 5; i++) {
const requestAbort = new AbortController();
await transport.send(
{ jsonrpc: '2.0', method: 'subscriptions/listen', id: `listen-${i}`, params: {} },
{ requestSignal: requestAbort.signal }
);
requestAbort.abort();
}
// ASSERT — every listener registered on the transport-lifetime
// signal was removed; nothing accrues per send().
expect(addSpy.mock.calls.length).toBeGreaterThan(0);
expect(removeSpy.mock.calls.length).toBe(addSpy.mock.calls.length);
} finally {
(AbortSignal as { any?: unknown }).any = nativeAny;
}
});
it('should NOT reconnect a POST stream when error response was received', async () => {
// ARRANGE
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
@ -1696,12 +2187,15 @@ describe('StreamableHTTPClientTransport', () => {
expect(tokenCalls.length).toBeGreaterThan(0);
// Verify tokens were saved
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
});
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
}),
expect.anything()
);
// Global fetch should never have been called
expect(globalThis.fetch).not.toHaveBeenCalled();
@ -1975,12 +2469,15 @@ describe('StreamableHTTPClientTransport', () => {
expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
expect((error as SdkHttpError).status).toBe(401);
expect((error as SdkHttpError).statusText).toBe('Unauthorized');
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'refresh-token' // Refresh token is preserved
});
expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith(
expect.objectContaining({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'refresh-token' // Refresh token is preserved
}),
expect.anything()
);
});
});

View file

@ -0,0 +1,708 @@
/**
* Connect-time version negotiation: option surface (Q5/Q12), probe mechanics
* (T9), corrective continuation (T2/A6), typed connect errors, fallback
* byte-equivalence at the message level, era scope discipline, and the
* probe-window guard.
*
* Wire-real HTTP first-contact shapes (the -32000 literal and the session-
* required 400) are exercised against real server transports in
* test/integration/test/client/versionNegotiation.test.ts.
*/
import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core-internal';
import {
isJSONRPCRequest,
PROTOCOL_VERSION_META_KEY,
SdkError,
SdkErrorCode,
UnsupportedProtocolVersionError
} from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { Client } from '../../src/client/client';
import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp';
import type { StdioServerParameters } from '../../src/client/stdio';
import { resolveVersionNegotiation } from '../../src/client/versionNegotiation';
const MODERN = '2026-07-28';
/* ------------------------------------------------------------------------- *
* Q5: option home — dissolved transport/stdio negotiation surfaces stay gone.
* ------------------------------------------------------------------------- */
describe('option surface (Q5/Q12)', () => {
test('no Transport.negotiation, no transport/stdio negotiation or probeTimeoutMs options (dissolved surfaces)', () => {
type NotAKeyOf<T, K extends string> = K extends keyof T ? false : true;
const transportHasNoNegotiation: NotAKeyOf<Transport, 'negotiation'> = true;
const httpOptionsHaveNoNegotiation: NotAKeyOf<StreamableHTTPClientTransportOptions, 'negotiation'> = true;
const stdioHasNoNegotiation: NotAKeyOf<StdioServerParameters, 'negotiation'> = true;
const stdioHasNoProbeTimeout: NotAKeyOf<StdioServerParameters, 'probeTimeoutMs'> = true;
expect(transportHasNoNegotiation).toBe(true);
expect(httpOptionsHaveNoNegotiation).toBe(true);
expect(stdioHasNoNegotiation).toBe(true);
expect(stdioHasNoProbeTimeout).toBe(true);
});
test('absent versionNegotiation resolves to the legacy arm (today’s default; the deferred default ruling is a one-line flip)', () => {
expect(resolveVersionNegotiation(undefined, undefined)).toEqual({ kind: 'legacy' });
expect(resolveVersionNegotiation({}, undefined)).toEqual({ kind: 'legacy' });
expect(resolveVersionNegotiation({ mode: 'legacy' }, undefined)).toEqual({ kind: 'legacy' });
});
test('auto resolves default-agnostically: explicit mode never consults the default', () => {
const auto = resolveVersionNegotiation({ mode: 'auto' }, undefined);
expect(auto).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true });
});
test('a consumer supportedProtocolVersions list drives the offer and the fallback availability', () => {
const modernOnly = resolveVersionNegotiation({ mode: 'auto' }, [MODERN]);
expect(modernOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: false });
const mixed = resolveVersionNegotiation({ mode: 'auto' }, ['2027-01-01', MODERN, '2025-11-25']);
expect(mixed).toMatchObject({ kind: 'auto', modernVersions: ['2027-01-01', MODERN], fallbackAvailable: true });
const legacyOnly = resolveVersionNegotiation({ mode: 'auto' }, ['2025-11-25']);
expect(legacyOnly).toMatchObject({ kind: 'auto', modernVersions: [MODERN], fallbackAvailable: true });
});
test('pin requires a modern revision', () => {
expect(resolveVersionNegotiation({ mode: { pin: MODERN } }, undefined)).toMatchObject({ kind: 'pin', version: MODERN });
expect(() => resolveVersionNegotiation({ mode: { pin: '2025-11-25' } }, undefined)).toThrow(TypeError);
});
});
/* ------------------------------------------------------------------------- *
* Scripted transport for probe mechanics.
* ------------------------------------------------------------------------- */
type Script = (message: JSONRPCMessage, transport: ScriptedTransport) => void;
class ScriptedTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
startCalls = 0;
sent: JSONRPCMessage[] = [];
setProtocolVersionCalls: string[] = [];
constructor(private readonly script: Script) {}
async start(): Promise<void> {
this.startCalls++;
if (this.startCalls > 1) {
throw new Error('ScriptedTransport already started! (double-start)');
}
}
async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
const deliver = () => this.script(message, this);
queueMicrotask(deliver);
}
async close(): Promise<void> {
this.onclose?.();
}
setProtocolVersion(version: string): void {
this.setProtocolVersionCalls.push(version);
}
reply(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}
const discoverResult = (supportedVersions: string[]) => ({
supportedVersions,
capabilities: {},
serverInfo: { name: 'scripted-modern-server', version: '1.0.0' }
});
/** A scripted dual-era server: answers server/discover with a DiscoverResult and initialize like a 2025 server. */
function modernServerScript(supportedVersions: string[] = [MODERN]): Script {
return (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult(supportedVersions) });
}
};
}
/** A scripted 2025 server: -32601 for unknown methods, a plain initialize result otherwise. */
const legacyServerScript: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'initialize') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: {
protocolVersion: '2025-11-25',
capabilities: {},
serverInfo: { name: 'scripted-legacy-server', version: '1.0.0' }
}
});
} else {
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
}
};
const requests = (sent: JSONRPCMessage[]): JSONRPCRequest[] => sent.filter(isJSONRPCRequest);
/* ------------------------------------------------------------------------- *
* Probe mechanics (T9) + modern resolution.
* ------------------------------------------------------------------------- */
describe('auto mode against a modern server', () => {
test('probe-first with a string id, no initialize, setProtocolVersion exactly once after era resolution', async () => {
const transport = new ScriptedTransport(modernServerScript());
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
const sent = requests(transport.sent);
expect(sent).toHaveLength(1);
const probe = sent[0]!;
// T9: never probe with the first real request; string probe id (no
// collision with Protocol's numeric ids on shared pipes).
expect(probe.method).toBe('server/discover');
expect(typeof probe.id).toBe('string');
expect(String(probe.id)).toMatch(/^server-discover-probe-/);
// The probe carries the preferred version in its own _meta envelope.
const meta = (probe.params as { _meta?: Record<string, unknown> })._meta;
expect(meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN);
// No initialize, no notifications/initialized on the modern era.
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
expect(transport.sent.some(m => 'method' in m && m.method === 'notifications/initialized')).toBe(false);
// The transport version slot was never mutated during negotiation; it is
// stamped exactly once, after the era resolved modern.
expect(transport.setProtocolVersionCalls).toEqual([MODERN]);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
expect(client.getServerVersion()?.name).toBe('scripted-modern-server');
await client.close();
});
test('the probe window hands the started transport to Protocol.connect without a double start', async () => {
const transport = new ScriptedTransport(modernServerScript());
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
// ScriptedTransport.start throws on a second call — reaching here proves
// the handover absorbed Protocol.connect's unconditional start() exactly once.
expect(transport.startCalls).toBe(1);
await client.close();
});
});
/* ------------------------------------------------------------------------- *
* Fallback: byte-equivalence at the message level + zero version-slot writes.
* ------------------------------------------------------------------------- */
describe('auto mode against a legacy server (fallback)', () => {
test('falls back to initialize on the SAME connection; post-probe traffic is identical to a plain legacy connect', async () => {
const autoTransport = new ScriptedTransport(legacyServerScript);
const autoClient = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await autoClient.connect(autoTransport);
const plainTransport = new ScriptedTransport(legacyServerScript);
const plainClient = new Client({ name: 'c', version: '0' });
await plainClient.connect(plainTransport);
// Diff-asserted fallback hygiene: drop the probe, then the auto client's
// entire outbound sequence must be byte-identical to the plain legacy
// client's (same initialize id 0, same body incl. protocolVersion).
const autoSentAfterProbe = autoTransport.sent.slice(1);
expect(JSON.stringify(autoSentAfterProbe)).toBe(JSON.stringify(plainTransport.sent));
// Same setProtocolVersion behavior as the plain path (once, with the
// initialize-negotiated version) — nothing was set or cleared around the probe.
expect(autoTransport.setProtocolVersionCalls).toEqual(plainTransport.setProtocolVersionCalls);
expect(autoClient.getNegotiatedProtocolVersion()).toBe('2025-11-25');
expect(plainClient.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await autoClient.close();
await plainClient.close();
});
test('option-parameterized oracle: a custom supportedProtocolVersions list flows into the fallback initialize body', async () => {
const versions = ['2025-06-18', '2025-03-26'];
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'initialize') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 's', version: '1' } }
});
} else {
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
}
};
const autoTransport = new ScriptedTransport(script);
const autoClient = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: versions }
);
await autoClient.connect(autoTransport);
const plainTransport = new ScriptedTransport(script);
const plainClient = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: versions });
await plainClient.connect(plainTransport);
expect(JSON.stringify(autoTransport.sent.slice(1))).toBe(JSON.stringify(plainTransport.sent));
const init = requests(autoTransport.sent)[1]!;
expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-06-18');
await autoClient.close();
await plainClient.close();
});
test('a dual-era supportedProtocolVersions list never leaks a 2026 version into the fallback initialize', async () => {
const transport = new ScriptedTransport(legacyServerScript);
const client = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] }
);
await client.connect(transport);
// The fallback initialize offers the first LEGACY version of the list,
// never the 2026-era entry.
const init = requests(transport.sent).find(r => r.method === 'initialize')!;
expect((init.params as { protocolVersion?: string }).protocolVersion).toBe('2025-11-25');
expect(JSON.stringify(transport.sent.slice(1))).not.toContain(MODERN);
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await client.close();
});
test('a non-conforming server that echoes a 2026 revision from initialize is rejected by the accept check', async () => {
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'initialize') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: { protocolVersion: MODERN, capabilities: {}, serverInfo: { name: 's', version: '1' } }
});
} else {
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
}
};
const transport = new ScriptedTransport(script);
const client = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN, '2025-11-25'] }
);
await expect(client.connect(transport)).rejects.toThrow(/protocol version is not supported/);
});
test('a modern-only client in auto mode gets a typed error instead of a fallback when the server gives no modern evidence', async () => {
const transport = new ScriptedTransport(legacyServerScript);
const client = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] }
);
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
// The fallback never ran: no initialize carrying any version was sent.
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
// Fallback against REAL servers (in-memory pair, stateful HTTP, stateless
// HTTP — both first-contact wire shapes) is covered in
// test/integration/test/client/versionNegotiation.test.ts.
});
/* ------------------------------------------------------------------------- *
* Probe timeout policy: transport-aware. On HTTP-class transports a timeout
* is a typed connect error (silence on a deployed server is an outage); on
* stdio it is a legacy-server signal and falls back to initialize on the same
* stream (the stdio transport's backward-compatibility rule — some legacy
* servers do not respond to unknown pre-initialize requests at all).
* ------------------------------------------------------------------------- */
describe('probe timeout policy (transport-aware)', () => {
const silentScript: Script = () => {
/* never replies */
};
test('HTTP-class transport: timeout rejects with the standard typed timeout error and is never converted to a legacy verdict', async () => {
const transport = new ScriptedTransport(silentScript);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 50 } } });
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout
);
// Never a legacy verdict: no initialize was attempted, before or after the timeout.
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
expect(requests(transport.sent)).toHaveLength(1);
expect(transport.setProtocolVersionCalls).toEqual([]);
});
/** A stdio-shaped transport: structurally recognizable by its stderr/pid accessors. */
class StdioShapedTransport extends ScriptedTransport {
get stderr(): null {
return null;
}
get pid(): number {
return 4242;
}
}
test('stdio-class transport: a server that never answers the probe is a legacy server — initialize fallback on the same stream', async () => {
// A silent legacy stdio server: ignores the unknown server/discover
// request entirely, but answers initialize like any 2025 server.
const silentLegacyScript: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'initialize') {
legacyServerScript(message, t);
}
// Anything else (the probe) is ignored — no reply at all.
};
const transport = new StdioShapedTransport(silentLegacyScript);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 30 } } });
await client.connect(transport);
// The timeout resolved to the legacy verdict and the initialize fallback
// ran on the SAME transport.
const sent = requests(transport.sent);
expect(sent.filter(r => r.method === 'server/discover')).toHaveLength(1);
expect(sent.some(r => r.method === 'initialize')).toBe(true);
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await client.close();
});
test('stdio-class transport: pin mode still fails loudly on a silent server (no fallback)', async () => {
const transport = new StdioShapedTransport(() => {
/* never replies */
});
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN }, probe: { timeoutMs: 30 } } });
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
test('maxRetries (default 0) governs timeout re-sends only; the timeout verdict applies after retries are exhausted', async () => {
// HTTP-class: even with retries, a server that never answers produces a
// typed timeout error after maxRetries+1 probe sends — never a legacy verdict.
const transport = new ScriptedTransport(silentScript);
const client = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 2 } } }
);
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout
);
const probes = transport.sent.filter(m => 'method' in m && m.method === 'server/discover');
expect(probes).toHaveLength(3);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
test('maxRetries: a server that answers on the first retry resolves normally (the retry budget is timeout-only)', async () => {
let discoverCalls = 0;
const slowThenFastScript: Script = (message, t) => {
if (!isJSONRPCRequest(message) || message.method !== 'server/discover') return;
discoverCalls++;
// Ignore the first probe (forces a timeout); answer the retry.
if (discoverCalls === 1) return;
t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) });
};
const transport = new ScriptedTransport(slowThenFastScript);
const client = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto', probe: { timeoutMs: 20, maxRetries: 1 } } }
);
await client.connect(transport);
expect(discoverCalls).toBe(2);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
await client.close();
});
});
/* ------------------------------------------------------------------------- *
* -32022 corrective continuation — exactly once; loop guard on second
* rejection.
* ------------------------------------------------------------------------- */
describe('-32022 corrective continuation', () => {
test('select-and-continue runs exactly once, even when the mutual version equals the just-rejected one', async () => {
let discoverCalls = 0;
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
discoverCalls++;
if (discoverCalls === 1) {
// Buggy-but-modern server: rejects the version it itself lists.
t.reply({
jsonrpc: '2.0',
id: message.id,
error: {
code: -32_022,
message: 'Unsupported protocol version',
data: { supported: [MODERN], requested: MODERN }
}
});
} else {
t.reply({ jsonrpc: '2.0', id: message.id, result: discoverResult([MODERN]) });
}
}
};
const transport = new ScriptedTransport(script);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
// The corrective continuation is spec-mandated: the second probe still happened.
expect(discoverCalls).toBe(2);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
// MUST NOT fall back at any point.
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
await client.close();
});
test('the loop guard arms on the second rejection: typed error, never an infinite continuation', async () => {
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
t.reply({
jsonrpc: '2.0',
id: message.id,
error: { code: -32_022, message: 'Unsupported protocol version', data: { supported: [MODERN], requested: MODERN } }
});
};
const transport = new ScriptedTransport(script);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError);
expect(requests(transport.sent)).toHaveLength(2);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
test('-32022 with a disjoint-but-modern list: typed error, never initialize', async () => {
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
t.reply({
jsonrpc: '2.0',
id: message.id,
error: { code: -32_022, message: 'Unsupported protocol version', data: { supported: ['2027-12-31'] } }
});
};
const transport = new ScriptedTransport(script);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await expect(client.connect(transport)).rejects.toBeInstanceOf(UnsupportedProtocolVersionError);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
test('-32022 with a legacy-only list: definitive legacy signal, initialize on the same connection', async () => {
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({
jsonrpc: '2.0',
id: message.id,
error: { code: -32_022, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } }
});
} else {
legacyServerScript(message, t);
}
};
const transport = new ScriptedTransport(script);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await client.close();
});
test('modern-only client + legacy-only -32022 list: typed error carrying data.supported', async () => {
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
t.reply({
jsonrpc: '2.0',
id: message.id,
error: { code: -32_022, message: 'Unsupported protocol version', data: { supported: ['2025-11-25'] } }
});
};
const transport = new ScriptedTransport(script);
const client = new Client(
{ name: 'c', version: '0' },
{ versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] }
);
const rejection = await client.connect(transport).then(
() => undefined,
error => error as UnsupportedProtocolVersionError
);
expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError);
expect(rejection!.supported).toEqual(['2025-11-25']);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
});
/* ------------------------------------------------------------------------- *
* Pin mode: no fallback, loud failure.
* ------------------------------------------------------------------------- */
describe('pin mode', () => {
test('modern era at the pinned version when the server offers it', async () => {
const transport = new ScriptedTransport(modernServerScript([MODERN, '2027-01-01']));
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await client.connect(transport);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
await client.close();
});
test('a legacy server fails loudly — no initialize fallback', async () => {
const transport = new ScriptedTransport(legacyServerScript);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
test('a modern server without the pinned version fails with typed data — never initialize', async () => {
const transport = new ScriptedTransport(modernServerScript(['2027-12-31']));
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
const rejection = await client.connect(transport).then(
() => undefined,
error => error as UnsupportedProtocolVersionError
);
expect(rejection).toBeInstanceOf(UnsupportedProtocolVersionError);
expect(rejection!.supported).toEqual(['2027-12-31']);
expect(rejection!.requested).toBe(MODERN);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
});
test('a failed negotiation leaves the transport start() untouched (no armed pass-through)', async () => {
const transport = new ScriptedTransport(legacyServerScript);
const originalStart = transport.start;
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await expect(client.connect(transport)).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
// The probe window's one-shot start() pass-through must not stay armed
// on a transport the caller still owns after a failed connect.
expect(transport.start).toBe(originalStart);
expect(transport.onmessage).toBeUndefined();
});
});
/* ------------------------------------------------------------------------- *
* Probe-window guard: pre-init server→client traffic mid-probe is dropped
* with zero bytes.
* ------------------------------------------------------------------------- */
describe('probe-window guard', () => {
test('a 2025-legal pre-init server→client request arriving mid-probe is dropped with zero bytes', async () => {
const script: Script = (message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
// The server pushes a ping BEFORE answering the probe (legal on a
// 2025 stdio pipe). It must be dropped — no response bytes.
t.reply({ jsonrpc: '2.0', id: 999, method: 'ping' });
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
} else {
legacyServerScript(message, t);
}
};
const transport = new ScriptedTransport(script);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);
// Zero bytes for the dropped request: nothing in the sent log answers id 999.
const repliesTo999 = transport.sent.filter(m => 'id' in m && m.id === 999);
expect(repliesTo999).toEqual([]);
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await client.close();
});
});
/* ------------------------------------------------------------------------- *
* Scope discipline: era is connection state — re-negotiated on every fresh
* connect, never silently demoted on the current connection.
* ------------------------------------------------------------------------- */
describe('era scope discipline', () => {
test('every fresh auto connect re-runs negotiation: no verdict survives a reconnect', async () => {
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
// First connect: probe, then fallback.
const first = new ScriptedTransport(legacyServerScript);
await client.connect(first);
expect(requests(first.sent)[0]!.method).toBe('server/discover');
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await client.close();
// Second (fresh) connect: the negotiated protocol version is connection
// state and is cleared at fresh connect — the probe runs again instead
// of replaying the previous connection's verdict.
const second = new ScriptedTransport(legacyServerScript);
await client.connect(second);
expect(requests(second.sent)[0]!.method).toBe('server/discover');
expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
await client.close();
});
test('an established modern era is never silently demoted: later failures surface, only the NEXT connect re-negotiates', async () => {
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
const transport = new ScriptedTransport(modernServerScript());
await client.connect(transport);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
// A later transport failure does not demote the current connection's era
// and triggers no initialize.
transport.onerror?.(new Error('boom'));
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
expect(transport.sent.some(m => 'method' in m && m.method === 'initialize')).toBe(false);
await client.close();
// The next connect re-runs negotiation (the discover exchange doubles as
// the capability fetch).
const next = new ScriptedTransport(modernServerScript());
await client.connect(next);
expect(requests(next.sent)[0]!.method).toBe('server/discover');
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
await client.close();
});
test('no era state exists before the first connect, and none is persisted anywhere', () => {
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
// No cachedEra option surface (deferred-additive).
type NotAKeyOf<T, K extends string> = K extends keyof T ? false : true;
const noCachedEra: NotAKeyOf<NonNullable<ConstructorParameters<typeof Client>[1]>, 'cachedEra'> = true;
expect(noCachedEra).toBe(true);
});
});