Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
20 changed files with 69131 additions and 9001 deletions
File diff suppressed because it is too large
Load diff
128
packages/client/test/client/bodyDerivedProbeHeaders.test.ts
Normal file
128
packages/client/test/client/bodyDerivedProbeHeaders.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
packages/client/test/client/clientTypeSurface.test.ts
Normal file
30
packages/client/test/client/clientTypeSurface.test.ts
Normal 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>();
|
||||
});
|
||||
});
|
||||
198
packages/client/test/client/connectPrior.test.ts
Normal file
198
packages/client/test/client/connectPrior.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
101
packages/client/test/client/discover.test.ts
Normal file
101
packages/client/test/client/discover.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
248
packages/client/test/client/envelopeAutoEmission.test.ts
Normal file
248
packages/client/test/client/envelopeAutoEmission.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
374
packages/client/test/client/inputRequiredEngine.test.ts
Normal file
374
packages/client/test/client/inputRequiredEngine.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
984
packages/client/test/client/listen.test.ts
Normal file
984
packages/client/test/client/listen.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
510
packages/client/test/client/mcpParamMirroring.test.ts
Normal file
510
packages/client/test/client/mcpParamMirroring.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
145
packages/client/test/client/modernEraInboundDrop.test.ts
Normal file
145
packages/client/test/client/modernEraInboundDrop.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
313
packages/client/test/client/probeClassifier.test.ts
Normal file
313
packages/client/test/client/probeClassifier.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
231
packages/client/test/client/probeFixtureCorpus.test.ts
Normal file
231
packages/client/test/client/probeFixtureCorpus.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
1268
packages/client/test/client/responseCache.test.ts
Normal file
1268
packages/client/test/client/responseCache.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
|
|
|
|||
69
packages/client/test/client/stdioEnvPins.test.ts
Normal file
69
packages/client/test/client/stdioEnvPins.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
708
packages/client/test/client/versionNegotiation.test.ts
Normal file
708
packages/client/test/client/versionNegotiation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue