Implement MCP 2026-07-28 (#2286)

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

View file

@ -8,20 +8,29 @@
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import type { AuthProvider, OAuthClientProvider } from '@modelcontextprotocol/client';
import type { AuthProvider, OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/client';
import {
applyMiddlewares,
auth,
AuthorizationServerMismatchError,
Client,
ClientCredentialsProvider,
computeScopeUnion,
createMiddleware,
discoverAuthorizationServerMetadata,
discoverOAuthProtectedResourceMetadata,
exchangeAuthorization,
InsecureTokenEndpointError,
InsufficientScopeError,
isStrictScopeSuperset,
IssuerMismatchError,
OAuthError,
OAuthErrorCode,
PrivateKeyJwtProvider,
refreshAuthorization,
registerClient,
RegistrationRejectedError,
resolveClientMetadata,
SdkError,
SSEClientTransport,
SseError,
@ -29,6 +38,7 @@ import {
StaticPrivateKeyJwtProvider,
StreamableHTTPClientTransport,
UnauthorizedError,
validateAuthorizationResponseIssuer,
withLogging,
withOAuth
} from '@modelcontextprotocol/client';
@ -36,14 +46,17 @@ import type {
AuthorizationServerMetadata,
OAuthClientInformationFull,
OAuthClientInformationMixed,
OAuthTokens
OAuthClientMetadata,
OAuthTokens,
StoredOAuthClientInformation,
StoredOAuthTokens
} from '@modelcontextprotocol/server';
import { LATEST_PROTOCOL_VERSION, McpServer } from '@modelcontextprotocol/server';
import { importSPKI, jwtVerify } from 'jose';
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { hostPerSession } from '../helpers/index';
import { defined, hostPerSession } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
@ -51,16 +64,11 @@ const ISSUER = 'https://auth.example.com';
const MCP_URL = 'http://in-process/mcp';
const RESOURCE = 'http://in-process/mcp';
// Narrows indexed-access results that the surrounding count assertions have already proven to exist.
function defined<T>(value: T | undefined, label: string): T {
if (value === undefined) throw new Error(`Expected ${label} to be defined`);
return value;
}
interface MockASConfig {
tokenResponses?: Array<Partial<OAuthTokens>>;
tokenErrorResponses?: Array<{ error: string; error_description?: string }>;
registerResponse?: Partial<OAuthClientInformationFull>;
registerErrorResponse?: { status: number; error: string; error_description?: string };
asMetadata?: Partial<AuthorizationServerMetadata>;
prmMetadata?: Record<string, unknown>;
noPRMDiscovery?: boolean;
@ -155,6 +163,10 @@ function createMockAuthorizationServer(config: MockASConfig = {}) {
if (path === '/register' && req.method === 'POST') {
const body = z.record(z.string(), z.unknown()).parse(await req.json());
registerCalls.push({ body });
if (config.registerErrorResponse) {
const { status, ...err } = config.registerErrorResponse;
return Response.json(err, { status, headers: { 'Content-Type': 'application/json' } });
}
// RFC 7591: the registration response echoes the submitted metadata plus issued credentials.
const response = {
...body,
@ -190,6 +202,7 @@ class RecordingOAuthClientProvider implements OAuthClientProvider {
tokens?: OAuthTokens;
clientInformation?: OAuthClientInformationMixed;
clientMetadataUrl?: string;
clientMetadata?: Partial<OAuthClientMetadata>;
} = {}
) {
if (initial.tokens) this.saved.tokens = initial.tokens;
@ -204,10 +217,11 @@ class RecordingOAuthClientProvider implements OAuthClientProvider {
return this.initial.clientMetadataUrl;
}
get clientMetadata() {
get clientMetadata(): OAuthClientMetadata {
return {
client_name: 'Test Client',
redirect_uris: [this.redirectUrl]
redirect_uris: [this.redirectUrl],
...this.initial.clientMetadata
};
}
@ -417,12 +431,13 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => {
await interactiveClient.close();
}
// Phase 2: when the upscoped token is still rejected with the same header, the transport stops instead of looping.
// Phase 2: when the upscoped token is still rejected, the transport stops at the per-send retry cap instead of looping.
// The token here already covers the challenged scope, so refresh (not a fresh authorization) is used.
const refreshAs = createMockAuthorizationServer({
tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer' }]
tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer', scope: UPGRADED_SCOPE }]
});
const refreshProvider = new RecordingOAuthClientProvider({
tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token' },
tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token', scope: UPGRADED_SCOPE },
clientInformation: { client_id: 'pre-registered-client' }
});
@ -445,7 +460,7 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => {
try {
const connectPromise = refreshClient.connect(refreshTransport);
await expect(connectPromise).rejects.toBeInstanceOf(SdkError);
await expect(connectPromise).rejects.toThrow(/403 after trying upscoping/);
await expect(connectPromise).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/);
expect(refreshAs.tokenCalls).toHaveLength(1);
expect(defined(refreshAs.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token');
@ -455,6 +470,194 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => {
}
});
verifies('client-auth:stepup:scope-union', async (_args: TestArgs) => {
// computeScopeUnion is a deliberate public export.
expect(computeScopeUnion('files:read openid', 'files:write')).toBe('files:read openid files:write');
expect(computeScopeUnion('admin', 'read')).toBe('admin read'); // no hierarchical collapse
// The transport requests the union of its previously-requested scope and the
// newly-challenged scope on step-up.
const PREVIOUS_SCOPE = 'files:read openid';
const CHALLENGED_SCOPE = 'files:write';
const as = createMockAuthorizationServer();
const provider = new RecordingOAuthClientProvider({
tokens: { access_token: 'read-token', token_type: 'Bearer', scope: PREVIOUS_SCOPE },
clientInformation: { client_id: 'pre-registered-client' }
});
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
return as.handleRequest(new Request(url, init));
}
return new Response(null, {
status: 403,
headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${CHALLENGED_SCOPE}"` }
});
};
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
expect(provider.redirectedTo).toHaveLength(1);
const redirect = defined(provider.redirectedTo[0], 'authorize URL');
expect(redirect.searchParams.get('scope')).toBe('files:read openid files:write');
} finally {
await client.close();
}
});
verifies(['client-auth:stepup:retry-cap', 'client-auth:stepup:refresh-bypass-on-superset'], async (_args: TestArgs) => {
// Part A — superset bypass: token granted "files:read"; challenged scope adds
// "files:write". Union strictly exceeds the token's grant → auth() forces a
// fresh authorization request (no refresh-token POST observed).
{
const as = createMockAuthorizationServer();
const provider = new RecordingOAuthClientProvider({
tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read' },
clientInformation: { client_id: 'pre-registered-client' }
});
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
return as.handleRequest(new Request(url, init));
}
return new Response(null, {
status: 403,
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' }
});
};
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
// Refresh was bypassed: no token-endpoint POST; the fresh authorize
// request carries the union scope.
expect(as.tokenCalls).toHaveLength(0);
expect(provider.redirectedTo).toHaveLength(1);
expect(defined(provider.redirectedTo[0], 'authorize URL').searchParams.get('scope')).toBe('files:read files:write');
expect(isStrictScopeSuperset('files:read files:write', 'files:read')).toBe(true);
} finally {
await client.close();
}
}
// Part B — refresh used + retry cap: token already covers the challenged
// scope (server is misconfigured / hierarchical). Union is NOT a strict
// superset → refresh is used. Server keeps 403'ing → per-send retry cap
// (default 1) stops the loop after exactly one step-up.
{
const as = createMockAuthorizationServer({
tokenResponses: [{ access_token: 't2', token_type: 'Bearer', scope: 'files:read files:write' }]
});
const provider = new RecordingOAuthClientProvider({
tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read files:write' },
clientInformation: { client_id: 'pre-registered-client' }
});
const mcpPosts: string[] = [];
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
return as.handleRequest(new Request(url, init));
}
mcpPosts.push(urlObj.pathname);
return new Response(null, {
status: 403,
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' }
});
};
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
const connectPromise = client.connect(transport);
await expect(connectPromise).rejects.toBeInstanceOf(SdkError);
await expect(connectPromise).rejects.toThrow(/retry limit 1 reached/);
expect(as.tokenCalls).toHaveLength(1);
expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token');
expect(provider.redirectedTo).toHaveLength(0);
expect(mcpPosts).toHaveLength(2);
expect(isStrictScopeSuperset('files:read files:write', 'files:read files:write')).toBe(false);
} finally {
await client.close();
}
}
});
verifies('client-auth:stepup:throw-mode', async (_args: TestArgs) => {
const as = createMockAuthorizationServer();
const provider = new RecordingOAuthClientProvider({
tokens: { access_token: 't', token_type: 'Bearer' },
clientInformation: { client_id: 'pre-registered-client' }
});
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
return as.handleRequest(new Request(url, init));
}
return new Response(null, {
status: 403,
headers: {
'WWW-Authenticate': `Bearer error="insufficient_scope", scope="files:write", resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource", error_description="write permission required"`
}
});
};
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
authProvider: provider,
fetch: combinedFetch,
onInsufficientScope: 'throw'
});
try {
const connectPromise = client.connect(transport);
await expect(connectPromise).rejects.toBeInstanceOf(InsufficientScopeError);
await expect(connectPromise).rejects.toMatchObject({
requiredScope: 'files:write',
errorDescription: 'write permission required',
resourceMetadataUrl: new URL(`${MCP_URL}/.well-known/oauth-protected-resource`)
});
// No re-authorization was attempted.
expect(as.tokenCalls).toHaveLength(0);
expect(as.discoveryCalls).toHaveLength(0);
expect(provider.redirectedTo).toHaveLength(0);
} finally {
await client.close();
}
});
verifies('client-auth:stepup:get-stream-403', async (_args: TestArgs) => {
// The GET listen-stream open path applies the same step-up handling.
// We assert via 'throw' mode (parity with the POST path is the same private
// helper) so the test observes the GET branch reaching the step-up gate.
const provider = new RecordingOAuthClientProvider({
tokens: { access_token: 't', token_type: 'Bearer' },
clientInformation: { client_id: 'pre-registered-client' }
});
const seenMethods: string[] = [];
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
seenMethods.push(init?.method ?? 'GET');
return new Response(null, {
status: 403,
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="listen"' }
});
};
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
authProvider: provider,
fetch: combinedFetch,
onInsufficientScope: 'throw'
});
await transport.start();
try {
const resumePromise = transport.resumeStream('last-event-42');
await expect(resumePromise).rejects.toBeInstanceOf(InsufficientScopeError);
await expect(resumePromise).rejects.toMatchObject({ requiredScope: 'listen' });
expect(seenMethods).toEqual(['GET']);
} finally {
await transport.close();
}
});
verifies('client-auth:as-metadata-discovery:priority-order', async (_args: TestArgs) => {
const oauthMetadata: AuthorizationServerMetadata = {
issuer: ISSUER,
@ -519,7 +722,11 @@ verifies('client-auth:as-metadata-discovery:issuer-validation', async (_args: Te
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
await expect(client.connect(transport)).rejects.toThrow(/issuer/i);
const err: unknown = await client.connect(transport).catch((error: unknown) => error);
expect(err).toBeInstanceOf(IssuerMismatchError);
expect((err as IssuerMismatchError).kind).toBe('metadata');
// Intentionally not an OAuthError — the auth() retry block must not swallow it.
expect(err).not.toBeInstanceOf(OAuthError);
// The mismatched metadata is rejected before registering, redirecting the user, or requesting tokens.
expect(as.registerCalls).toHaveLength(0);
@ -531,6 +738,152 @@ verifies('client-auth:as-metadata-discovery:issuer-validation', async (_args: Te
}
});
/**
* Runs the redirect leg of the OAuth flow against a mock AS configured with `asMetadata`, then
* calls `transport.finishAuth(...)`. When `callback` is a string (or undefined) it is passed as
* `finishAuth('granted-code', iss)`; when it is a `URLSearchParams` it is passed verbatim to the
* overload. Returns the thrown error (or undefined on success) and the recorded token-endpoint
* calls so the caller can assert whether the code went on the wire.
*/
async function runFinishAuthScenario(asMetadata: Partial<AuthorizationServerMetadata>, callback: string | undefined | URLSearchParams) {
const as = createMockAuthorizationServer({ asMetadata, tokenResponses: [{ access_token: 'iss-flow-token', token_type: 'Bearer' }] });
const provider = new RecordingOAuthClientProvider();
const mcpHost = createAuthenticatedHost('iss-flow-token');
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'iss-flow-token' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
const client = new Client({ name: 'c', version: '0' });
try {
// First connect → 401 → discovery → REDIRECT.
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
expect(provider.redirectedTo).toHaveLength(1);
let thrown: unknown;
try {
await (callback instanceof URLSearchParams ? transport.finishAuth(callback) : transport.finishAuth('granted-code', callback));
} catch (error) {
thrown = error;
}
return { thrown, tokenCalls: as.tokenCalls, provider };
} finally {
await client.close();
await mcpHost.close();
}
}
verifies('client-auth:iss:match', async (_args: TestArgs) => {
const { thrown, tokenCalls, provider } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, ISSUER);
expect(thrown).toBeUndefined();
expect(tokenCalls).toHaveLength(1);
expect(defined(tokenCalls[0], 'token call').body.get('code')).toBe('granted-code');
expect(provider.saved.tokens?.access_token).toBe('iss-flow-token');
});
verifies('client-auth:iss:mismatch-reject', async (_args: TestArgs) => {
const { thrown, tokenCalls } = await runFinishAuthScenario(
{ authorization_response_iss_parameter_supported: true },
'https://attacker.example.com'
);
expect(thrown).toBeInstanceOf(IssuerMismatchError);
expect((thrown as IssuerMismatchError).kind).toBe('authorization_response');
expect((thrown as IssuerMismatchError).expected).toBe(ISSUER);
// The authorization code never reaches a token endpoint.
expect(tokenCalls).toHaveLength(0);
});
verifies('client-auth:iss:supported-missing-reject', async (_args: TestArgs) => {
const { thrown, tokenCalls } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, undefined);
expect(thrown).toBeInstanceOf(IssuerMismatchError);
expect((thrown as IssuerMismatchError).received).toBeUndefined();
expect(tokenCalls).toHaveLength(0);
});
verifies('client-auth:iss:unadvertised-proceed', async (_args: TestArgs) => {
// Row 4: not advertised + iss absent → the exchange proceeds. Also covers row 3 (not advertised
// + iss present → still compared) by additionally asserting the same scenario rejects a wrong iss.
const proceed = await runFinishAuthScenario({}, undefined);
expect(proceed.thrown).toBeUndefined();
expect(proceed.tokenCalls).toHaveLength(1);
const reject = await runFinishAuthScenario({}, 'https://attacker.example.com');
expect(reject.thrown).toBeInstanceOf(IssuerMismatchError);
expect(reject.tokenCalls).toHaveLength(0);
});
verifies('client-auth:iss:no-normalize', async (_args: TestArgs) => {
// Each value is URL-equivalent to ISSUER under RFC 3986 §6.2.2-6.2.3 normalization, but
// RFC 9207 mandates simple string comparison — every one MUST be rejected.
for (const iss of [ISSUER.toUpperCase(), `${ISSUER}/`, `${ISSUER}:443`, ISSUER.replace('https', 'HTTPS')]) {
expect(() => validateAuthorizationResponseIssuer({ iss, expectedIssuer: ISSUER, issParameterSupported: true })).toThrow(
IssuerMismatchError
);
}
// And end-to-end through finishAuth(): a trailing-slash difference is a real reject.
const { thrown, tokenCalls } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, `${ISSUER}/`);
expect(thrown).toBeInstanceOf(IssuerMismatchError);
expect(tokenCalls).toHaveLength(0);
});
verifies('client-auth:iss:opt-out', async (_args: TestArgs) => {
// skipIssuerMetadataValidation suppresses AU-02 (metadata echo): mismatched-issuer metadata is accepted.
const as = createMockAuthorizationServer({ asMetadata: { issuer: 'https://misconfigured.example.com' } });
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
const provider = new RecordingOAuthClientProvider();
await auth(provider, { serverUrl: MCP_URL, skipIssuerMetadataValidation: true, fetchFn });
expect(provider.redirectedTo).toHaveLength(1);
// It does NOT suppress AU-01 (callback iss): a mismatched iss is still rejected before token exchange.
await expect(
auth(provider, {
serverUrl: MCP_URL,
authorizationCode: 'granted-code',
iss: 'https://attacker.example.com',
skipIssuerMetadataValidation: true,
fetchFn
})
).rejects.toThrow(IssuerMismatchError);
expect(as.tokenCalls).toHaveLength(0);
});
verifies('client-auth:finishauth:urlsearchparams-sanitizes', async (_args: TestArgs) => {
const ATTACKER_TEXT = 'ATTACKER_CONTROLLED_DO_NOT_DISPLAY';
const ATTACKER_URI = 'https://attacker.example.com/phish';
// Mismatched-iss callback that ALSO carries error/error_description/error_uri — a mix-up
// attacker controls all of these. The overload must throw IssuerMismatchError before reading
// them, so none of the attacker text appears on the thrown error.
const mixed = await runFinishAuthScenario(
{ authorization_response_iss_parameter_supported: true },
new URLSearchParams({
code: 'granted-code',
state: 'state-123',
iss: 'https://attacker.example.com',
error: 'server_error',
error_description: ATTACKER_TEXT,
error_uri: ATTACKER_URI
})
);
expect(mixed.thrown).toBeInstanceOf(IssuerMismatchError);
const err = mixed.thrown as IssuerMismatchError;
expect(err.kind).toBe('authorization_response');
expect(err.message).not.toContain(ATTACKER_TEXT);
expect(err.message).not.toContain(ATTACKER_URI);
expect(JSON.stringify(err)).not.toContain(ATTACKER_TEXT);
// The poisoned code never reached a token endpoint.
expect(mixed.tokenCalls).toHaveLength(0);
// Happy path: matching iss → SDK extracts code and redeems it.
const ok = await runFinishAuthScenario(
{ authorization_response_iss_parameter_supported: true },
new URLSearchParams({ code: 'granted-code', state: 'state-123', iss: ISSUER })
);
expect(ok.thrown).toBeUndefined();
expect(ok.tokenCalls).toHaveLength(1);
expect(defined(ok.tokenCalls[0], 'token call').body.get('code')).toBe('granted-code');
expect(ok.provider.saved.tokens?.access_token).toBe('iss-flow-token');
});
verifies('client-auth:bearer-header:every-request', async (_args: TestArgs) => {
const validToken = 'bearer-test-token';
const provider = new RecordingOAuthClientProvider({
@ -706,9 +1059,11 @@ verifies('client-auth:invalid-client-clears-all', async (_args: TestArgs) => {
expect(as.tokenCalls).toHaveLength(1);
expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token');
// Everything is invalidated: tokens are gone and the stale client_id was discarded,
// forcing a fresh dynamic registration on the retry.
expect(provider.invalidatedCredentials).toContain('all');
// Client + tokens are invalidated (NOT 'all', so discoveryState survives — SEP-2352):
// tokens are gone and the stale client_id was discarded, forcing a fresh dynamic
// registration on the retry.
expect(provider.invalidatedCredentials).toContain('client');
expect(provider.invalidatedCredentials).toContain('tokens');
expect(provider.saved.tokens).toBeUndefined();
expect(as.registerCalls).toHaveLength(1);
expect(provider.saved.clientInformation?.client_id).toBe('registered-client-id');
@ -921,8 +1276,10 @@ verifies('client-auth:prm-discovery:fallback-order', async (_args: TestArgs) =>
verifies('client-auth:prm-discovery:no-prm-fallback', async (_args: TestArgs) => {
const VALID = 'legacy-fallback-token';
// RFC 8414 §3.3: metadata fetched at the MCP origin must claim that origin as its issuer.
const as = createMockAuthorizationServer({
noPRMDiscovery: true,
asMetadata: { issuer: new URL(MCP_URL).origin },
tokenResponses: [{ access_token: VALID, token_type: 'Bearer' }]
});
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'legacy-fallback-client' } });
@ -2038,3 +2395,456 @@ verifies('client-transport:sse:401-unauthorized-code', async (_args: TestArgs) =
await authTransport.close();
}
});
// --- SEP-837 / SEP-2207 (DCR hygiene) -------------------------------------------------
verifies(['client-auth:dcr:app-type-heuristic', 'client-auth:dcr:grant-types-default'], async (_args: TestArgs) => {
const as = createMockAuthorizationServer();
// No application_type, no grant_types in clientMetadata; redirect_uri is loopback.
const provider = new RecordingOAuthClientProvider();
const mcpHost = createAuthenticatedHost('dcr-token');
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'dcr-token' });
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
expect(as.registerCalls).toHaveLength(1);
const body = defined(as.registerCalls[0], 'registration call').body;
// SEP-837: loopback redirect URI → 'native' by heuristic.
expect(body.application_type).toBe('native');
// SEP-2207: omitted grant_types → defaulted to include refresh_token.
expect(body.grant_types).toEqual(['authorization_code', 'refresh_token']);
} finally {
await client.close();
await mcpHost.close();
}
});
verifies(
'client-auth:dcr:app-type-heuristic',
async (_args: TestArgs) => {
// Heuristic 'web' branch: drive registerClient() with a resolveClientMetadata() result so the
// test can use a non-loopback redirect URI without going through auth()'s redirectUrl plumbing.
const as = createMockAuthorizationServer();
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
await registerClient(ISSUER, {
clientMetadata: resolveClientMetadata({
clientMetadata: { client_name: 'web-app', redirect_uris: ['https://app.example.com/callback'] },
redirectUrl: 'https://app.example.com/callback'
}),
fetchFn
});
expect(as.registerCalls).toHaveLength(1);
expect(defined(as.registerCalls[0], 'registration call').body.application_type).toBe('web');
},
{ title: "non-loopback https redirect URI defaults to 'web'" }
);
verifies('client-auth:dcr:app-type-override', async (_args: TestArgs) => {
const as = createMockAuthorizationServer();
// Loopback redirect URI but the consumer explicitly says 'web' (e.g. web app dev-served on localhost).
const provider = new RecordingOAuthClientProvider({ clientMetadata: { application_type: 'web' } });
const mcpHost = createAuthenticatedHost('dcr-token');
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'dcr-token' });
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
expect(as.registerCalls).toHaveLength(1);
const body = defined(as.registerCalls[0], 'registration call').body;
expect(body.application_type).toBe('web'); // heuristic would have picked 'native'
} finally {
await client.close();
await mcpHost.close();
}
});
verifies('client-auth:dcr:grant-types-default', async (_args: TestArgs) => {
// Consumer-set grant_types is never rewritten.
const as = createMockAuthorizationServer();
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
await registerClient(ISSUER, {
clientMetadata: { client_name: 'm2m', redirect_uris: ['http://localhost:3000/cb'], grant_types: ['client_credentials'] },
fetchFn
});
expect(as.registerCalls).toHaveLength(1);
expect(defined(as.registerCalls[0], 'registration call').body.grant_types).toEqual(['client_credentials']);
});
verifies('client-auth:dcr:registration-rejected-error', async (_args: TestArgs) => {
const as = createMockAuthorizationServer({
registerErrorResponse: { status: 400, error: 'invalid_redirect_uri', error_description: 'loopback not permitted' }
});
const provider = new RecordingOAuthClientProvider();
const mcpHost = createAuthenticatedHost('never');
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'never' });
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
const err = await client.connect(transport).catch((error: unknown) => error);
// Propagates through auth() — not caught by the OAuthError retry path.
expect(err).toBeInstanceOf(RegistrationRejectedError);
expect(err).not.toBeInstanceOf(OAuthError);
const rre = err as RegistrationRejectedError;
expect(rre.status).toBe(400);
expect(rre.body).toContain('invalid_redirect_uri');
// Submitted metadata reflects what was POSTed (after defaults applied) so callers can adjust+retry.
expect(rre.submittedMetadata.application_type).toBe('native');
expect(rre.submittedMetadata.redirect_uris).toEqual(['http://localhost:3000/callback']);
// Exactly one /register call — the auth() recovery path did not silently retry.
expect(as.registerCalls).toHaveLength(1);
} finally {
await client.close();
await mcpHost.close();
}
});
verifies('client-auth:token-endpoint:https-guard', async (_args: TestArgs) => {
// AS metadata advertises a non-https, non-loopback token endpoint.
const as = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://auth.example.com/token' } });
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'https-guard-client' } });
provider.saveCodeVerifier('verifier');
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
// Exchange path through auth(): redeeming an authorization code is rejected before the request is sent.
await expect(auth(provider, { serverUrl: MCP_URL, authorizationCode: 'code', fetchFn })).rejects.toThrow(InsecureTokenEndpointError);
expect(as.tokenCalls).toHaveLength(0);
// Refresh path through auth(): a stored refresh_token + non-https token endpoint surfaces the
// configuration error to the caller — it is NOT swallowed into a silent /authorize redirect.
const refreshProvider = new RecordingOAuthClientProvider({
clientInformation: { client_id: 'https-guard-client' },
tokens: { access_token: 'old', token_type: 'Bearer', refresh_token: 'rt' }
});
await expect(auth(refreshProvider, { serverUrl: MCP_URL, fetchFn })).rejects.toThrow(InsecureTokenEndpointError);
expect(as.tokenCalls).toHaveLength(0);
expect(refreshProvider.redirectedTo).toHaveLength(0);
// And the lower-level helper rejects with the same dedicated class.
await expect(
refreshAuthorization(ISSUER, {
metadata: {
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: 'http://auth.example.com/token',
response_types_supported: ['code']
},
clientInformation: { client_id: 'https-guard-client' },
refreshToken: 'rt',
fetchFn
})
).rejects.toThrow(InsecureTokenEndpointError);
expect(as.tokenCalls).toHaveLength(0);
// Loopback exemption: the in-process mock AS itself uses an https issuer; cover the exemption
// directly so a future tightening of the guard does not silently break local-dev / test setups.
const loopbackAs = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://127.0.0.1:9001/token' } });
// Route the loopback token URL to the mock.
const loopbackFetch = (url: URL | string, init?: RequestInit) => loopbackAs.handleRequest(new Request(url, init));
await expect(
refreshAuthorization(ISSUER, {
metadata: {
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: 'http://127.0.0.1:9001/token',
response_types_supported: ['code']
},
clientInformation: { client_id: 'https-guard-client' },
refreshToken: 'rt',
fetchFn: loopbackFetch
})
).resolves.toBeDefined();
});
verifies('client-auth:refresh:rotation-handling', async (_args: TestArgs) => {
const clientInformation = { client_id: 'rotation-client', client_secret: 's' };
const as = createMockAuthorizationServer({
tokenResponses: [
{ access_token: 'a1', token_type: 'Bearer' }, // no refresh_token issued
{ access_token: 'a2', token_type: 'Bearer' }, // refresh: AS omits refresh_token → keep prior
{ access_token: 'a3', token_type: 'Bearer', refresh_token: 'rt-new' } // refresh: rotated
]
});
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
// No-assume-issuance: token response without refresh_token parses cleanly.
const t1 = await exchangeAuthorization(ISSUER, {
clientInformation,
authorizationCode: 'code',
codeVerifier: 'verifier',
redirectUri: 'http://localhost:3000/callback',
fetchFn
});
expect(t1.refresh_token).toBeUndefined();
// Prior refresh_token preserved when AS omits a replacement.
const t2 = await refreshAuthorization(ISSUER, { clientInformation, refreshToken: 'rt-old', fetchFn });
expect(t2.refresh_token).toBe('rt-old');
// Rotated refresh_token adopted when AS returns one.
const t3 = await refreshAuthorization(ISSUER, { clientInformation, refreshToken: 'rt-old', fetchFn });
expect(t3.refresh_token).toBe('rt-new');
});
verifies('client-auth:scope:offline-access-gate', async (_args: TestArgs) => {
// AS advertises offline_access; provider explicitly sets grant_types including refresh_token,
// so offline_access is appended to the requested scope on authorize.
const asWith = createMockAuthorizationServer({ asMetadata: { scopes_supported: ['openid', 'offline_access'] } });
const providerWith = new RecordingOAuthClientProvider({
clientMetadata: { grant_types: ['authorization_code', 'refresh_token'] }
});
const fetchWith = createCombinedFetch({ as: asWith, mcpHost: createAuthenticatedHost('never'), validToken: 'never' });
await auth(providerWith, { serverUrl: MCP_URL, fetchFn: fetchWith });
const redirectWith = defined(providerWith.redirectedTo[0], 'authorization redirect URL');
expect(redirectWith.searchParams.get('scope')?.split(' ')).toContain('offline_access');
// AS does NOT advertise offline_access → never appended.
const asWithout = createMockAuthorizationServer({ asMetadata: { scopes_supported: ['openid'] } });
const providerWithout = new RecordingOAuthClientProvider();
const fetchWithout = createCombinedFetch({ as: asWithout, mcpHost: createAuthenticatedHost('never'), validToken: 'never' });
await auth(providerWithout, { serverUrl: MCP_URL, fetchFn: fetchWithout });
const redirectWithout = defined(providerWithout.redirectedTo[0], 'authorization redirect URL');
expect((redirectWithout.searchParams.get('scope') ?? '').split(' ')).not.toContain('offline_access');
});
// ---------------------------------------------------------------------------
// SEP-2352 — per-authorization-server credential isolation. Stored tokens and
// client credentials carry an SDK-stamped `issuer`; a value stamped for a
// different AS reads back as undefined, so it is never reused on the wire.
// ---------------------------------------------------------------------------
/**
* A protected resource whose `authorization_servers` PRM entry can be swapped
* between calls. Each issuer hosts its own DCR endpoint that mints a distinct
* `client_id`, so reuse of an old-AS `client_id` is observable on the wire.
*/
function createMigratingAuthorizationServer() {
const issuers = { one: 'https://as-one.example.com', two: 'https://as-two.example.com' } as const;
let active: keyof typeof issuers = 'one';
const registerCalls: Array<{ issuer: string }> = [];
const clientIdsSeen: Array<{ issuer: string; clientId: string | null }> = [];
const tokenCalls: Array<{ issuer: string; body: URLSearchParams }> = [];
const asMetadata = (issuer: string): AuthorizationServerMetadata => ({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
registration_endpoint: `${issuer}/register`,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
client_id_metadata_document_supported: true,
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials']
});
const fetchFn = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const u = typeof url === 'string' ? new URL(url) : url;
if (u.pathname.includes('/.well-known/oauth-protected-resource')) {
return Response.json({ resource: RESOURCE, authorization_servers: [issuers[active]] });
}
if (u.pathname.includes('/.well-known/oauth-authorization-server') || u.pathname.includes('/.well-known/openid-configuration')) {
return Response.json(asMetadata(u.origin));
}
if (u.pathname === '/register' && init?.method === 'POST') {
const body = z.record(z.string(), z.unknown()).parse(JSON.parse(String(init.body)));
registerCalls.push({ issuer: u.origin });
return Response.json({ ...body, client_id: `cid-at-${u.host}`, client_secret: `secret-at-${u.host}` }, { status: 201 });
}
if (u.pathname === '/authorize') {
clientIdsSeen.push({ issuer: u.origin, clientId: u.searchParams.get('client_id') });
return new Response('Authorize', { status: 200 });
}
if (u.pathname === '/token' && init?.method === 'POST') {
const body = new URLSearchParams(String(init.body));
clientIdsSeen.push({ issuer: u.origin, clientId: body.get('client_id') });
tokenCalls.push({ issuer: u.origin, body });
return Response.json({ access_token: `tok-${u.host}`, token_type: 'Bearer' });
}
return new Response('Not Found', { status: 404 });
};
return {
issuers,
registerCalls,
clientIdsSeen,
tokenCalls,
fetchFn,
switchTo(which: keyof typeof issuers) {
active = which;
}
};
}
/** Single-slot blob provider — round-trips the SDK-stamped values verbatim. */
class StampedBlobProvider implements OAuthClientProvider {
redirectedTo: URL[] = [];
info?: StoredOAuthClientInformation;
storedTokens?: StoredOAuthTokens;
discovery?: OAuthDiscoveryState;
private _verifier?: string;
constructor(public readonly clientMetadataUrl?: string) {}
get redirectUrl() {
return 'http://localhost:3000/callback';
}
get clientMetadata() {
return { client_name: 'Test Client', redirect_uris: [this.redirectUrl] };
}
clientInformation() {
return this.info;
}
saveClientInformation(i: OAuthClientInformationMixed) {
this.info = i;
}
tokens() {
return this.storedTokens;
}
saveTokens(t: OAuthTokens) {
this.storedTokens = t;
}
redirectToAuthorization(u: URL) {
this.redirectedTo.push(u);
}
saveCodeVerifier(v: string) {
this._verifier = v;
}
codeVerifier() {
if (!this._verifier) throw new Error('no verifier');
return this._verifier;
}
discoveryState() {
return this.discovery;
}
saveDiscoveryState(s: OAuthDiscoveryState) {
this.discovery = s;
}
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') {
if (scope === 'all' || scope === 'discovery') this.discovery = undefined;
}
}
verifies('client-auth:as-migration:reregister', async (_args: TestArgs) => {
const server = createMigratingAuthorizationServer();
const provider = new StampedBlobProvider();
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
expect(server.registerCalls).toEqual([{ issuer: server.issuers.one }]);
expect(provider.info?.issuer).toBe(server.issuers.one);
// PRM now lists AS-two. Drop cached discovery (as a host would on a fresh 401) so
// re-discovery picks up the new AS.
server.switchTo('two');
provider.invalidateCredentials('discovery');
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
// Stamp mismatch → undefined → re-registers at AS-two, redirect carries the fresh client_id.
expect(server.registerCalls).toEqual([{ issuer: server.issuers.one }, { issuer: server.issuers.two }]);
const redirect = defined(provider.redirectedTo.at(-1), 'second redirect');
expect(redirect.origin).toBe(server.issuers.two);
expect(redirect.searchParams.get('client_id')).toBe('cid-at-as-two.example.com');
});
verifies('client-auth:as-migration:no-cred-reuse', async (_args: TestArgs) => {
const server = createMigratingAuthorizationServer();
const provider = new StampedBlobProvider();
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
expect(provider.info?.client_id).toBe('cid-at-as-one.example.com');
server.switchTo('two');
provider.invalidateCredentials('discovery');
await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn });
// Wire MUST: AS-two never received AS-one's client_id at any endpoint.
for (const seen of server.clientIdsSeen.filter(c => c.issuer === server.issuers.two)) {
expect(seen.clientId).not.toBe('cid-at-as-one.example.com');
}
expect(provider.info?.client_id).toBe('cid-at-as-two.example.com');
expect(provider.info?.issuer).toBe(server.issuers.two);
});
verifies('client-auth:as-migration:no-token-reuse', async (_args: TestArgs) => {
const server = createMigratingAuthorizationServer();
const provider = new StampedBlobProvider();
// Completed flow against AS-one — provider holds an AS-one-stamped refresh_token.
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn, authorizationCode: 'code-a' })).toBe('AUTHORIZED');
provider.storedTokens = { ...defined(provider.storedTokens, 'tokens'), refresh_token: 'rt-one' };
expect(provider.storedTokens.issuer).toBe(server.issuers.one);
server.switchTo('two');
provider.invalidateCredentials('discovery');
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
// Wire MUST: AS-two's /token never received a refresh_token grant or rt-one.
for (const { body } of server.tokenCalls.filter(c => c.issuer === server.issuers.two)) {
expect(body.get('grant_type')).not.toBe('refresh_token');
expect(body.get('refresh_token')).not.toBe('rt-one');
}
});
verifies('client-auth:as-migration:cimd-portable', async (_args: TestArgs) => {
const cimdUrl = 'https://client.example.com/.well-known/client-metadata.json';
const server = createMigratingAuthorizationServer();
const provider = new StampedBlobProvider(cimdUrl);
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
expect(server.registerCalls).toHaveLength(0);
expect(provider.info?.client_id).toBe(cimdUrl);
server.switchTo('two');
provider.invalidateCredentials('discovery');
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
// No DCR; the same URL-based client_id is presented to the new AS (re-stamped).
expect(server.registerCalls).toHaveLength(0);
const redirect = defined(provider.redirectedTo.at(-1), 'second redirect');
expect(redirect.origin).toBe(server.issuers.two);
expect(redirect.searchParams.get('client_id')).toBe(cimdUrl);
expect(provider.info?.issuer).toBe(server.issuers.two);
});
verifies('client-auth:as-migration:m2m-expected-issuer', async (_args: TestArgs) => {
const server = createMigratingAuthorizationServer();
// expectedIssuer = AS-one, but PRM points to AS-two → stamp mismatch → undefined →
// no saveClientInformation → AuthorizationServerMismatchError before any token request.
server.switchTo('two');
const provider = new ClientCredentialsProvider({
clientId: 'static-cid',
clientSecret: 'static-secret',
expectedIssuer: server.issuers.one
});
await expect(auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).rejects.toThrow(AuthorizationServerMismatchError);
expect(server.tokenCalls.filter(c => c.issuer === server.issuers.two)).toHaveLength(0);
expect(server.clientIdsSeen.filter(c => c.issuer === server.issuers.two)).toHaveLength(0);
// Matching expectedIssuer proceeds and stamps the saved tokens.
server.switchTo('one');
const ok = new ClientCredentialsProvider({
clientId: 'static-cid',
clientSecret: 'static-secret',
expectedIssuer: server.issuers.one
});
expect(await auth(ok, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('AUTHORIZED');
expect(ok.tokens()?.issuer).toBe(server.issuers.one);
});

View file

@ -44,7 +44,10 @@ verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) =
await using _ = await wire(transport, makeServer, client);
const inFlightCall = client.callTool({ name: 'emit-log', arguments: {} });
// On a 2026-era request the spec says an absent `_meta.logLevel` envelope key means the server MUST NOT
// send notifications/message — so the entryModern arm needs the key set explicitly for the log to be
// emitted. Legacy-era arms ignore the key (the session-scoped level applies; absent → no filter).
const inFlightCall = client.callTool({ name: 'emit-log', arguments: {}, _meta: { 'io.modelcontextprotocol/logLevel': 'debug' } });
try {
// The handler is parked on the gate, so the tools/call request is still in flight when the log arrives.
await vi.waitFor(() => expect(logs).toHaveLength(1));

View file

@ -0,0 +1,337 @@
/**
* Bearer auth composed with the dual-era HTTP entry (`createMcpHandler`), and
* per-request HTTP context exposure on it. These are the entry-side siblings of
* `hosting:auth:authinfo-propagates` / `hosting:auth:missing-401` /
* `hosting:context:web-request-headers`, whose bodies hand-host an Express or
* per-session stack and so never reach `createMcpHandler` when given an entry
* arm.
*
* The SDK does not enforce endpoint authentication on either era — bearer auth
* is deployer-composed middleware in front of whichever handler is mounted.
* The composition under test here is the documented one: a user-shaped gate
* verifies the Authorization header and, on success, hands the verified
* `AuthInfo` to `handler.fetch(request, { authInfo })`. The entry never derives
* `authInfo` from request headers; it is strictly pass-through to the factory's
* per-request context and to handler `ctx.http.authInfo`. Each cell hosts the
* composition itself behind an in-process fetch (the wire() entry arm has no
* hook for the gate or for client-transport requestInit), and the matrix arm
* selects which leg of the entry serves the authenticated traffic
* (`entryStateless` → a plain client through the stateless legacy fallback;
* `entryModern` → a 2026-07-28-pinned client through the modern-only strict
* path).
*/
import { Client, InsufficientScopeError, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import type { AuthInfo, McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
const MODERN = '2026-07-28';
const VALID_TOKEN = 'e2e-entry-access-token';
/** What the user's verifier derives from the Authorization header (a fresh object per request, so the assertion checks delivery, not identity). */
function verifyBearer(header: string | null): AuthInfo | undefined {
if (header !== `Bearer ${VALID_TOKEN}`) return undefined;
return {
token: VALID_TOKEN,
clientId: 'e2e-entry-caller',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
extra: { userId: 'user-42' }
};
}
/** The 401 a bearer gate answers a missing/invalid token with (mirrors the spec's WWW-Authenticate discovery shape). */
function unauthorized(): Response {
return Response.json(
{ error: 'invalid_token' },
{
status: 401,
headers: {
'content-type': 'application/json',
'www-authenticate':
'Bearer error="invalid_token", resource_metadata="http://in-process/.well-known/oauth-protected-resource"'
}
}
);
}
verifies('typescript:hosting:entry:auth:missing-401', async ({ transport }: TestArgs) => {
let factoryCalls = 0;
const factory = (_ctx?: McpRequestContext): McpServer => {
factoryCalls++;
const server = new McpServer({ name: 'e2e-entry-auth', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('whoami', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'reached' }] }));
return server;
};
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
await using _ = { [Symbol.asyncDispose]: () => handler.close() };
// The documented bearer-gate composition in front of the entry.
const gatedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const request = new Request(url, init);
const authInfo = verifyBearer(request.headers.get('authorization'));
if (authInfo === undefined) return unauthorized();
return handler.fetch(request, { authInfo });
};
// 1. Raw probe without an Authorization header: the gate answers 401 with
// the WWW-Authenticate challenge, and the entry is never reached.
const probe = await gatedFetch('http://in-process/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
});
expect(probe.status).toBe(401);
const wwwAuthenticate = probe.headers.get('www-authenticate');
expect(wwwAuthenticate).toContain('Bearer');
expect(wwwAuthenticate).toContain('resource_metadata');
expect(factoryCalls).toBe(0);
// 2. The plain SDK client (no Authorization on its requestInit) cannot
// connect through the gate on either leg, and the entry is still never
// reached. The exact connect-time error surface (401 wrapped by the
// legacy POST or by the modern discover negotiation) is a client-auth
// concern; this cell pins only that the gate composes — connect rejects
// and no factory call runs.
const plainClient = new Client({ name: 'plain-client', version: '1.0.0' });
if (transport === 'entryModern') plainClient.setVersionNegotiation({ mode: { pin: MODERN } });
try {
await expect(
plainClient.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: gatedFetch }))
).rejects.toThrow();
} finally {
await plainClient.close().catch(() => {});
}
expect(factoryCalls).toBe(0);
});
verifies('typescript:hosting:entry:auth:authinfo-propagates', async ({ transport }: TestArgs) => {
// Recorders live outside the per-request factory.
const seenByFactory: Array<AuthInfo | undefined> = [];
const seenByTool: Array<AuthInfo | undefined> = [];
const factory = (ctx?: McpRequestContext): McpServer => {
seenByFactory.push(ctx?.authInfo);
const server = new McpServer({ name: 'e2e-entry-auth', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool(
'whoami',
{ description: 'Reports the authenticated caller derived from ctx.http.authInfo.', inputSchema: z.object({}) },
(_args, handlerCtx) => {
seenByTool.push(handlerCtx.http?.authInfo);
return {
content: [
{
type: 'text',
text: handlerCtx.http?.authInfo
? `${handlerCtx.http.authInfo.clientId} [${handlerCtx.http.authInfo.scopes.join(' ')}] (${ctx?.era ?? 'unknown'})`
: 'no-auth-info'
}
]
};
}
);
return server;
};
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
const gatedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const request = new Request(url, init);
const authInfo = verifyBearer(request.headers.get('authorization'));
if (authInfo === undefined) return unauthorized();
return handler.fetch(request, { authInfo });
};
const client = new Client({ name: 'auth-client', version: '1.0.0' });
if (transport === 'entryModern') client.setVersionNegotiation({ mode: { pin: MODERN } });
await using _ = {
[Symbol.asyncDispose]: async () => {
await client.close().catch(() => {});
await handler.close();
}
};
await client.connect(
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: gatedFetch,
requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }
})
);
if (transport === 'entryModern') expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const result = await client.callTool({ name: 'whoami', arguments: {} });
expect(result.isError).toBeFalsy();
const era = transport === 'entryStateless' ? 'legacy' : 'modern';
expect(result.content).toEqual([{ type: 'text', text: `e2e-entry-caller [mcp:tools:read mcp:tools:call] (${era})` }]);
// The verified AuthInfo handed to handler.fetch(request, { authInfo })
// reached the tool handler's ctx.http.authInfo unchanged — not dropped, not
// replaced by a placeholder, not derived from a header.
expect(seenByTool).toHaveLength(1);
expect(seenByTool[0]).toEqual({
token: VALID_TOKEN,
clientId: 'e2e-entry-caller',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
extra: { userId: 'user-42' }
});
// ...and the same AuthInfo was on the factory's per-request context for
// every instance the entry built (negotiation + the tools/call), so a
// factory keying surface off authInfo sees it on the leg under test.
expect(seenByFactory.length).toBeGreaterThan(0);
for (const seen of seenByFactory) {
expect(seen).toEqual({
token: VALID_TOKEN,
clientId: 'e2e-entry-caller',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
extra: { userId: 'user-42' }
});
}
});
verifies('typescript:hosting:entry:ctx-http-req-headers', async ({ transport }: TestArgs) => {
const PROBE_HEADER = 'x-e2e-probe';
const PROBE_VALUE = 'probe-7d1f';
const seenByTool: Array<{ isFetchHeaders: boolean; probe: string | null }> = [];
const factory = (_ctx?: McpRequestContext): McpServer => {
const server = new McpServer({ name: 'e2e-entry-ctx', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('read-probe-header', { inputSchema: z.object({}) }, (_args, ctx) => {
const headers = ctx.http?.req?.headers;
seenByTool.push({
isFetchHeaders: headers instanceof Headers,
probe: headers instanceof Headers ? headers.get(PROBE_HEADER) : null
});
return { content: [{ type: 'text', text: headers?.get(PROBE_HEADER) ?? '<missing>' }] };
});
return server;
};
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
const client = new Client({ name: 'ctx-client', version: '1.0.0' });
if (transport === 'entryModern') client.setVersionNegotiation({ mode: { pin: MODERN } });
await using _ = {
[Symbol.asyncDispose]: async () => {
await client.close().catch(() => {});
await handler.close();
}
};
await client.connect(
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init)),
requestInit: { headers: { [PROBE_HEADER]: PROBE_VALUE } }
})
);
const result = await client.callTool({ name: 'read-probe-header', arguments: {} });
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: PROBE_VALUE }]);
// The custom header set on the client transport is readable as Fetch
// Headers inside the handler on the leg the matrix arm selected.
expect(seenByTool).toEqual([{ isFetchHeaders: true, probe: PROBE_VALUE }]);
});
verifies('typescript:hosting:entry:auth:insufficient-scope-403', async ({ transport }: TestArgs) => {
// Per-operation scope requirements derived from the body's tool name. On the
// modern leg the gate reads the SEP-2243 standard `Mcp-Method` / `Mcp-Name`
// headers (the entry's documented per-operation routing surface); the legacy
// leg has no such header so the gate falls back to one required scope.
const REQUIRED_BY_TOOL: Record<string, string> = { 'write-file': 'files:write' };
const TOKEN_SCOPES: Record<string, string[]> = {
'read-only-token': ['files:read'],
'read-write-token': ['files:read', 'files:write']
};
let factoryCalls = 0;
const factory = (ctx?: McpRequestContext): McpServer => {
factoryCalls++;
const server = new McpServer({ name: 'e2e-entry-scoped', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('list-files', { inputSchema: z.object({}) }, () => ({
content: [{ type: 'text', text: `listed by ${ctx?.authInfo?.clientId}` }]
}));
server.registerTool('write-file', { inputSchema: z.object({}) }, () => ({
content: [{ type: 'text', text: `written by ${ctx?.authInfo?.clientId}` }]
}));
return server;
};
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
await using _ = { [Symbol.asyncDispose]: () => handler.close() };
const insufficientScope = (required: string): Response =>
Response.json(
{ error: 'insufficient_scope' },
{
status: 403,
headers: {
'www-authenticate': `Bearer error="insufficient_scope", scope="${required}", error_description="${required} required for this operation"`
}
}
);
const gatedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const request = new Request(url, init);
const auth = request.headers.get('authorization');
const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined;
if (!token) return unauthorized();
const scopes = TOKEN_SCOPES[token];
if (scopes === undefined) return unauthorized();
const mcpName = request.headers.get('mcp-name') ?? undefined;
const required: string =
request.headers.get('mcp-method') === 'tools/call' && mcpName ? (REQUIRED_BY_TOOL[mcpName] ?? 'files:read') : 'files:read';
if (!scopes.includes(required)) return insufficientScope(required);
return handler.fetch(request, { authInfo: { token, clientId: 'e2e-scoped-caller', scopes } });
};
// 1. With the read-only token: list-files reaches the entry; write-file
// (modern leg) is rejected at the gate with 403 + insufficient_scope and
// the entry is never reached for that request.
const before = factoryCalls;
const readClient = new Client({ name: 'scoped-client', version: '1.0.0' });
if (transport === 'entryModern') readClient.setVersionNegotiation({ mode: { pin: MODERN } });
await readClient.connect(
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: gatedFetch,
requestInit: { headers: { Authorization: 'Bearer read-only-token' } },
onInsufficientScope: 'throw'
})
);
const listed = await readClient.callTool({ name: 'list-files', arguments: {} });
expect(listed.content).toEqual([{ type: 'text', text: 'listed by e2e-scoped-caller' }]);
const reachedAfterList = factoryCalls;
expect(reachedAfterList).toBeGreaterThan(before);
if (transport === 'entryModern') {
const writePromise = readClient.callTool({ name: 'write-file', arguments: {} });
await expect(writePromise).rejects.toBeInstanceOf(InsufficientScopeError);
await expect(writePromise).rejects.toMatchObject({ requiredScope: 'files:write' });
// The 403 came from the gate; no factory call ran for that POST.
expect(factoryCalls).toBe(reachedAfterList);
} else {
// Legacy leg: no Mcp-Name header → the gate's per-operation derivation
// is not available, so write-file passes the gate (single required scope
// fallback). The cell pins that the gate composes correctly with the
// legacy serving path; per-operation enforcement on legacy is host
// responsibility (e.g., by parsing the body).
const written = await readClient.callTool({ name: 'write-file', arguments: {} });
expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]);
}
await readClient.close().catch(() => {});
// 2. With the read-write token: write-file reaches the entry on both legs.
const rwClient = new Client({ name: 'scoped-client', version: '1.0.0' });
if (transport === 'entryModern') rwClient.setVersionNegotiation({ mode: { pin: MODERN } });
await rwClient.connect(
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: gatedFetch,
requestInit: { headers: { Authorization: 'Bearer read-write-token' } }
})
);
const written = await rwClient.callTool({ name: 'write-file', arguments: {} });
expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]);
await rwClient.close().catch(() => {});
});

View file

@ -0,0 +1,155 @@
/**
* HTTP request mechanics on the dual-era HTTP entry (`createMcpHandler`),
* exercised through the wire() entry arms. These are the entry-side siblings
* of the `hosting:http:*` / `hosting:stateless:*` rows, whose bodies hand-host
* the streamable HTTP server transport themselves and so never reach
* `createMcpHandler` when given an entry arm. Every probe here goes through
* `wired.fetch` against the harness-hosted entry so the HTTP status/body is
* observed directly; the matrix arm selects which leg of the entry answers it
* (`entryStateless` → the stateless legacy fallback; `entryModern` → the
* modern-only strict path).
*/
import { Client } from '@modelcontextprotocol/client';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { McpServer } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
const LEGACY = '2025-11-25';
/** One ctx-taking factory backing every cell. */
function echoFactory(_ctx?: McpRequestContext): McpServer {
const server = new McpServer({ name: 'e2e-entry-http', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text }]
}));
return server;
}
verifies('typescript:hosting:entry:method-405', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'method-405-client', version: '1.0.0' });
// No `entry` override: the arm posture (`stateless` on entryStateless,
// `reject` on entryModern) is the configuration under test.
await using wired = await wire(transport, echoFactory, client);
for (const method of ['GET', 'DELETE', 'PUT', 'PATCH']) {
const response = await wired.fetch!(wired.url!, { method });
expect(response.status).toBe(405);
const body = (await response.json()) as { jsonrpc: string; error: { code: number; message: string } };
expect(body.jsonrpc).toBe('2.0');
expect(body.error.code).toBe(-32_000);
expect(body.error.message).toMatch(/method not allowed/i);
}
});
verifies('typescript:hosting:entry:parse-error-400', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'parse-error-client', version: '1.0.0' });
await using wired = await wire(transport, echoFactory, client);
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: 'not json'
});
expect(response.status).toBe(400);
const body = (await response.json()) as { jsonrpc: string; error: { code: number } };
expect(body.jsonrpc).toBe('2.0');
expect(body.error.code).toBe(-32_700);
});
verifies('typescript:hosting:entry:legacy-accept-406', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'accept-406-client', version: '1.0.0' });
await using wired = await wire(transport, echoFactory, client);
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} });
// The legacy server transport requires both application/json and
// text/event-stream on POST: each single-type Accept (and an absent
// Accept) is rejected at 406 by the fallback the entry delegated to.
for (const accept of ['application/json', 'text/event-stream', undefined]) {
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: {
'mcp-protocol-version': LEGACY,
'content-type': 'application/json',
...(accept !== undefined && { accept })
},
body
});
expect(response.status).toBe(406);
}
});
verifies('typescript:hosting:entry:legacy-content-type-415', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'content-type-415-client', version: '1.0.0' });
await using wired = await wire(transport, echoFactory, client);
// A non-JSON Content-Type carrying a syntactically-JSON 2025-era body: the
// entry classifier reads the body (it does not gate on Content-Type), routes
// it legacy, and the fallback's transport answers 415 on Content-Type alone.
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: {
'mcp-protocol-version': LEGACY,
'content-type': 'text/plain',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
});
expect(response.status).toBe(415);
});
verifies('typescript:hosting:entry:legacy-protocol-version-header-400', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'protocol-version-400-client', version: '1.0.0' });
await using wired = await wire(transport, echoFactory, client);
// An unknown (and not modern-era) protocol-version header on a 2025-era
// body: the entry routes it legacy, and the fallback's transport answers
// the spec's 400 with the supported version(s) named in the body.
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: {
'mcp-protocol-version': '1999-01-01',
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
});
expect(response.status).toBe(400);
expect(await response.text()).toContain(LEGACY);
});
verifies('typescript:hosting:entry:legacy-protocol-version-default', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'protocol-version-default-client', version: '1.0.0' });
await using wired = await wire(transport, echoFactory, client);
// No MCP-Protocol-Version header at all: the legacy fallback assumes the
// spec's default version, so a 2025-era tools/list still round-trips.
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
});
expect(response.status).toBe(200);
expect(await response.text()).toContain('"echo"');
});
verifies('typescript:hosting:entry:no-session-id', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'no-session-id-client', version: '1.0.0' });
await using wired = await wire(transport, echoFactory, client);
// A typed round trip through the wired client (so both the connect-time
// negotiation and a follow-up request are recorded), then assert no
// exchange ever carried an Mcp-Session-Id response header.
const result = await client.callTool({ name: 'echo', arguments: { text: 'probe' } });
expect(result.content).toEqual([{ type: 'text', text: 'probe' }]);
expect(wired.httpLog!.length).toBeGreaterThan(0);
for (const exchange of wired.httpLog!) {
expect(exchange.response.headers.get('mcp-session-id')).toBeNull();
}
});

View file

@ -0,0 +1,197 @@
/**
* Sessionful 2025-era serving kept alive next to a strict dual-era HTTP entry
* through explicit user-land routing: the exported `isLegacyRequest` predicate
* (the entry's own classification step) decides, an existing sessionful wiring
* serves the legacy branch, and a strict (`legacy: 'reject'`) `createMcpHandler`
* serves everything else. This is the documented replacement for the removed
* handler-valued `legacy` option.
*
* The legacy wiring is real and sessionful — one
* WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by
* the Mcp-Session-Id the transport itself issues (the documented sessionful
* hosting pattern) — and a plain 2025 SDK client drives the full session
* lifecycle through the routed composition: initialize issues a session id, a
* follow-up POST is served on that session, the body-less GET opens the
* standalone SSE stream, and DELETE tears the session down. Every exchange the
* wiring serves is recorded as it leaves it (method, status, content-type), so
* the predicate's routing of GET/DELETE (no envelope, no body → legacy) is
* pinned directly; byte-level forwarding fidelity is not asserted here. An
* envelope-claiming probe at the end pins that modern traffic is answered by
* the strict entry, never by the legacy wiring.
*
* The composition is hosted by the test body itself (an in-process fetch in
* front of both handlers), so the wire() entry arm is not used; the matrix
* still bounds the cell to the 2025-11-25 axis via the requirement entry.
*/
import { randomUUID } from 'node:crypto';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import type { LegacyHttpHandler, McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { modernEnvelopeMeta } from '../helpers/index';
import { verifies } from '../helpers/verifies';
const LEGACY = '2025-11-25';
/** The factory backing the strict modern entry; legacy traffic never reaches it (the lifecycle under test is the legacy wiring's). */
function modernFactory(_ctx?: McpRequestContext): McpServer {
const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({
content: [{ type: 'text', text: `hello ${name} (modern)` }]
}));
return server;
}
verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => {
// The documented sessionful wiring, kept exactly as an existing deployment
// would have it: a fresh transport per initialize, kept in a map keyed by
// the Mcp-Session-Id it issues; later requests are routed by that header.
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
const closedSessions: string[] = [];
const sessionServers: McpServer[] = [];
async function routeSessionRequest(request: Request): Promise<Response> {
const sessionId = request.headers.get('mcp-session-id');
if (sessionId !== null) {
const existing = sessions.get(sessionId);
if (existing !== undefined) return existing.handleRequest(request);
// A request for a session this wiring no longer (or never) knew —
// the documented sessionful pattern answers 404.
return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 });
}
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void sessions.set(id, transport),
onsessionclosed: id => {
closedSessions.push(id);
sessions.delete(id);
}
});
const server = new McpServer({ name: 'sessionful-legacy-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({
content: [{ type: 'text', text: `hello ${name} (legacy session)` }]
}));
sessionServers.push(server);
await server.connect(transport);
return transport.handleRequest(request);
}
// Every exchange routed to the existing legacy wiring, recorded as it
// leaves the wiring: this is what proves the GET/DELETE routing.
const legacyExchanges: Array<{ method: string; status: number; contentType: string }> = [];
const sessionfulLegacy: LegacyHttpHandler = async request => {
const response = await routeSessionRequest(request);
legacyExchanges.push({
method: request.method.toUpperCase(),
status: response.status,
contentType: response.headers.get('content-type') ?? ''
});
return response;
};
// The documented user-land routing pattern: a strict modern entry plus the
// exported predicate in front of the existing legacy wiring.
const modern = createMcpHandler(modernFactory, { legacy: 'reject' });
const route = async (request: Request): Promise<Response> => {
if (await isLegacyRequest(request)) {
return sessionfulLegacy(request);
}
return modern.fetch(request);
};
const url = new URL('http://in-process/mcp');
const fetchViaRouter = (input: URL | string, init?: RequestInit) => route(new Request(input, init));
const client = new Client({ name: 'plain-2025-client', version: '1.0.0' });
try {
await client.connect(new StreamableHTTPClientTransport(url, { fetch: fetchViaRouter }));
// initialize → the sessionful wiring issues an Mcp-Session-Id. (The
// strict entry never issues one, so a defined session id alone proves
// the request was routed to the existing legacy wiring.)
expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY);
const clientTransport = client.transport as StreamableHTTPClientTransport;
const sessionId = clientTransport.sessionId;
expect(sessionId).toBeDefined();
expect(sessions.has(sessionId!)).toBe(true);
// Follow-up POST on the session: served by the same per-session instance.
const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } });
expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (legacy session)' }]);
expect(clientTransport.sessionId).toBe(sessionId);
// GET route: the client opens its standalone SSE stream after
// initialization; the predicate routes the body-less GET (no envelope)
// to the legacy wiring, which answers it with the stream.
await vi.waitFor(
() => {
const get = legacyExchanges.find(exchange => exchange.method === 'GET');
if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy wiring yet');
expect(get.status).toBe(200);
expect(get.contentType).toContain('text/event-stream');
},
{ timeout: 5000, interval: 50 }
);
// DELETE route: terminating the session goes through the predicate to
// the sessionful wiring, which tears the session down.
await clientTransport.terminateSession();
expect(closedSessions).toEqual([sessionId]);
const deleteExchange = legacyExchanges.find(exchange => exchange.method === 'DELETE');
expect(deleteExchange?.status).toBe(200);
// Stop the client before probing the dead session so its standalone
// stream cannot reconnect underneath the assertion.
await client.close();
// The dead session is gone: a POST carrying its id is answered 404 by
// the sessionful wiring, not silently re-served by anything else.
const stale = await fetchViaRouter(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-session-id': sessionId!,
'mcp-protocol-version': LEGACY
},
body: JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'tools/list', params: {} })
});
expect(stale.status).toBe(404);
await stale.text();
// ...and that 404 was produced by the sessionful wiring (the probe
// reached it), not synthesized by the entry or anything in front of it.
expect(legacyExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true);
// Modern traffic is the strict entry's: an envelope-claiming request is
// answered by the modern factory and never reaches the legacy wiring.
const exchangesBeforeModernProbe = legacyExchanges.length;
const modernProbe = await fetchViaRouter(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-method': 'tools/call',
'mcp-name': 'greet'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 100,
method: 'tools/call',
params: {
name: 'greet',
arguments: { name: 'router' },
_meta: modernEnvelopeMeta({ name: 'router-probe-client', version: '1.0.0' })
}
})
});
expect(modernProbe.status).toBe(200);
expect(await modernProbe.text()).toContain('hello router (modern)');
expect(legacyExchanges).toHaveLength(exchangesBeforeModernProbe);
} finally {
await client.close().catch(() => {});
await modern.close().catch(() => {});
for (const server of sessionServers) await server.close().catch(() => {});
}
});

View file

@ -0,0 +1,160 @@
/**
* Result stamping and cache-field fill, end to end over the dual-era HTTP
* entry (`createMcpHandler`), with the era boundary asserted on the wire:
*
* - the entryModern cell (2026-07-28 axis): typed tools/list, resources/read
* and resources/list round trips through the negotiating client succeed, and
* the recorded wire results carry `resultType: 'complete'` plus the required
* `ttlMs`/`cacheScope` fields, with three rungs of the documented precedence
* observable on the wire: the per-resource hint wins over the per-operation
* hint (resources/read), a per-operation hint wins over the defaults
* (tools/list), and a result with no configured author is filled with the
* `{ ttlMs: 0, cacheScope: 'private' }` defaults (resources/list). The top
* rung — a handler-returned value winning over every configured hint — is
* pinned at unit level (encodeContract), not here.
* - the entryStateless cell (2025-11-25 axis): the same fully
* cache-hint-configured factory served to a plain client through the legacy
* stateless slot answers the same calls with none of that vocabulary
* anywhere in the response bytes.
*
* Both cells run through the wire() entry arms; the raw response bytes come
* from the arm-recorded `wired.httpLog`.
*/
import { Client } from '@modelcontextprotocol/client';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { McpServer } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import type { Wired } from '../helpers/index';
import { wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
const LEGACY = '2025-11-25';
const MODERN = '2026-07-28';
/** The cache-field vocabulary that must never appear on a 2025-era response. */
const CACHE_VOCABULARY = ['"resultType"', '"ttlMs"', '"cacheScope"', '"cacheHint"'] as const;
/**
* One ctx-taking factory with every cache-hint author configured:
* - a per-operation hint for tools/list (the funnel-built result with no other author),
* - a per-operation hint for resources/read AND a per-resource hint on the
* registered resource, so the documented precedence (per-resource wins) is
* observable on the wire.
*/
function cacheConfiguredFactory(_ctx?: McpRequestContext): McpServer {
const server = new McpServer(
{ name: 'e2e-entry-cache', version: '1.0.0' },
{
capabilities: { tools: {}, resources: {} },
cacheHints: {
'tools/list': { ttlMs: 60_000, cacheScope: 'public' },
'resources/read': { ttlMs: 90_000, cacheScope: 'public' }
}
}
);
server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({
content: [{ type: 'text', text: `hello ${name}` }]
}));
server.registerResource('note', 'memo://note', { cacheHint: { ttlMs: 12_000, cacheScope: 'private' } }, async uri => ({
contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'cached note' }]
}));
return server;
}
/** The raw response bodies of every recorded HTTP exchange, in order. */
function responseBodies(wired: Wired): Promise<string[]> {
return Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text()));
}
/** Parses a captured response body (plain JSON or SSE-framed) into its JSON-RPC messages. */
function jsonRpcMessagesFrom(text: string): Array<Record<string, unknown>> {
if (text.trim() === '') return [];
if (text.includes('data: ')) {
return text
.split('\n')
.filter(line => line.startsWith('data: '))
.map(line => JSON.parse(line.slice(6)) as Record<string, unknown>);
}
try {
const parsed = JSON.parse(text) as Record<string, unknown> | Array<Record<string, unknown>>;
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [];
}
}
/** Finds the wire result of the response message whose result carries the given key. */
function wireResultWith(bodies: string[], key: string): Record<string, unknown> | undefined {
for (const body of bodies) {
for (const message of jsonRpcMessagesFrom(body)) {
const result = message.result as Record<string, unknown> | undefined;
if (result && key in result) return result;
}
}
return undefined;
}
verifies('typescript:hosting:entry:modern-cacheable-stamping', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'e2e-stamping-client', version: '1.0.0' });
await using wired = await wire(transport, cacheConfiguredFactory, client);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
// Typed round trips (the 2026 wire result schemas require the cache
// fields, so a successful decode is itself part of the assertion).
const list = await client.listTools();
expect(list.tools.map(tool => tool.name)).toEqual(['greet']);
const read = await client.readResource({ uri: 'memo://note' });
const firstContent = read.contents[0];
expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note');
const resourceList = await client.listResources();
expect(resourceList.resources.map(resource => resource.uri)).toEqual(['memo://note']);
// Wire-level: resultType is stamped and the cache fields carry the
// configured hints. tools/list has only the per-operation author (its
// hint wins over the defaults); resources/read shows the per-resource
// hint winning over the per-operation hint; resources/list has no
// configured author at all and is filled with the documented defaults.
const bodies = await responseBodies(wired);
const listResult = wireResultWith(bodies, 'tools');
expect(listResult).toBeDefined();
expect(listResult).toMatchObject({ resultType: 'complete', ttlMs: 60_000, cacheScope: 'public' });
const readResult = wireResultWith(bodies, 'contents');
expect(readResult).toBeDefined();
expect(readResult).toMatchObject({ resultType: 'complete', ttlMs: 12_000, cacheScope: 'private' });
const resourceListResult = wireResultWith(bodies, 'resources');
expect(resourceListResult).toBeDefined();
expect(resourceListResult).toMatchObject({ resultType: 'complete', ttlMs: 0, cacheScope: 'private' });
});
verifies('typescript:hosting:entry:legacy-cacheable-suppression', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'plain-2025-client', version: '1.0.0' });
await using wired = await wire(transport, cacheConfiguredFactory, client);
expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY);
// The same calls, typed, on the 2025 leg (served through the legacy stateless slot).
const tools = await client.listTools();
expect(tools.tools.map(tool => tool.name)).toEqual(['greet']);
const read = await client.readResource({ uri: 'memo://note' });
const firstContent = read.contents[0];
expect(firstContent && 'text' in firstContent ? firstContent.text : undefined).toBe('cached note');
// None of the 2026 cache vocabulary appears anywhere in the bytes of
// any response of this conversation, even though every cache-hint
// author is configured on the factory.
const bodies = await responseBodies(wired);
const conversation = bodies.join('\n');
expect(conversation).toContain('"tools"');
expect(conversation).toContain('"contents"');
for (const term of CACHE_VOCABULARY) {
expect(conversation).not.toContain(term);
}
});

View file

@ -0,0 +1,154 @@
/**
* Modern-era (2026-07-28) response streaming through the dual-era HTTP entry,
* exercised on the wire() entryModern arm:
*
* - default response mode: a handler that emits nothing before its result is
* answered as a single JSON body; a handler that emits related notifications
* mid-call upgrades the response to an SSE stream (content-type
* text/event-stream, notifications framed in emission order, terminal result
* last);
* - `responseMode: 'sse'` always streams, even with no mid-call output;
* - `responseMode: 'json'` never streams and drops mid-call notifications —
* only the terminal result is delivered.
*
* Every body drives the harness-hosted entry through the wired client (the
* entryModern arm pins it to 2026-07-28); the typed result and the raw wire
* bytes (status, content-type, SSE frames)
* are asserted side by side via the arm-recorded `wired.httpLog`.
*/
import { Client } from '@modelcontextprotocol/client';
import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server';
import { McpServer } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import type { Wired } from '../helpers/index';
import { wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
const MODERN = '2026-07-28';
/**
* One factory with a quiet tool (no streamed output) and a chatty tool (two
* logging notifications emitted before its result), so the lazy upgrade and
* both forced response modes are observable per call.
*/
function streamingFactory(_ctx?: McpRequestContext): McpServer {
const server = new McpServer({ name: 'e2e-entry-streaming', version: '1.0.0' }, { capabilities: { tools: {}, logging: {} } });
server.registerTool('quiet', { inputSchema: z.object({}) }, () => ({
content: [{ type: 'text', text: 'quiet result' }]
}));
server.registerTool('chatty', { inputSchema: z.object({}) }, async (_args, ctx) => {
await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'first' } });
await ctx.mcpReq.notify({ method: 'notifications/message', params: { level: 'info', data: 'second' } });
return { content: [{ type: 'text', text: 'chatty result' }] };
});
return server;
}
interface RecordedResponse {
status: number;
contentType: string;
body: string;
}
/** Every recorded HTTP response (status, content-type, raw body bytes), in exchange order. */
function recordedResponses(wired: Wired): Promise<RecordedResponse[]> {
return Promise.all(
(wired.httpLog ?? []).map(async exchange => ({
status: exchange.status,
contentType: exchange.contentType,
body: await exchange.response.text()
}))
);
}
/** The `data:` payloads of an SSE-framed body, parsed, in frame order. */
function sseDataFrames(body: string): Array<Record<string, unknown>> {
return body
.split('\n')
.filter(line => line.startsWith('data: '))
.map(line => JSON.parse(line.slice('data: '.length)) as Record<string, unknown>);
}
function newClient(): Client {
return new Client({ name: 'e2e-streaming-client', version: '1.0.0' });
}
function callTool(client: Client, name: 'quiet' | 'chatty'): Promise<CallToolResult> {
return client.callTool({ name, arguments: {} }) as Promise<CallToolResult>;
}
verifies('typescript:hosting:entry:modern-lazy-sse-upgrade', async ({ transport }: TestArgs) => {
const client = newClient();
await using wired = await wire(transport, streamingFactory, client);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
// Quiet handler: nothing emitted before the result → a single JSON body.
const quiet = await callTool(client, 'quiet');
expect(quiet.content).toEqual([{ type: 'text', text: 'quiet result' }]);
// Chatty handler: the first related notification upgrades the exchange
// to SSE — notifications framed in order, terminal result last.
const chatty = await callTool(client, 'chatty');
expect(chatty.content).toEqual([{ type: 'text', text: 'chatty result' }]);
const responses = await recordedResponses(wired);
const quietResponse = responses.find(response => response.body.includes('quiet result'));
expect(quietResponse).toBeDefined();
expect(quietResponse!.status).toBe(200);
expect(quietResponse!.contentType).toContain('application/json');
const chattyResponse = responses.find(response => response.body.includes('chatty result'));
expect(chattyResponse).toBeDefined();
expect(chattyResponse!.status).toBe(200);
expect(chattyResponse!.contentType).toContain('text/event-stream');
const frames = sseDataFrames(chattyResponse!.body);
expect(frames).toHaveLength(3);
expect(frames[0]).toMatchObject({ method: 'notifications/message', params: { data: 'first' } });
expect(frames[1]).toMatchObject({ method: 'notifications/message', params: { data: 'second' } });
expect(frames[2]).toMatchObject({ result: { content: [{ type: 'text', text: 'chatty result' }] } });
});
verifies('typescript:hosting:entry:modern-response-mode', async ({ transport }: TestArgs) => {
// One harness-hosted endpoint per responseMode value, both backed by the same factory.
// responseMode 'sse': even a handler that emits nothing streams its result.
{
const client = newClient();
await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'sse' } });
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const result = await callTool(client, 'quiet');
expect(result.content).toEqual([{ type: 'text', text: 'quiet result' }]);
const responses = await recordedResponses(wired);
const response = responses.find(candidate => candidate.body.includes('quiet result'));
expect(response).toBeDefined();
expect(response!.status).toBe(200);
expect(response!.contentType).toContain('text/event-stream');
const frames = sseDataFrames(response!.body);
expect(frames).toHaveLength(1);
expect(frames[0]).toMatchObject({ result: { content: [{ type: 'text', text: 'quiet result' }] } });
}
// responseMode 'json': mid-call notifications are dropped — the response
// is a plain JSON body whose only payload is the terminal result.
{
const client = newClient();
await using wired = await wire(transport, streamingFactory, client, { entry: { responseMode: 'json' } });
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const result = await callTool(client, 'chatty');
expect(result.content).toEqual([{ type: 'text', text: 'chatty result' }]);
const responses = await recordedResponses(wired);
const response = responses.find(candidate => candidate.body.includes('chatty result'));
expect(response).toBeDefined();
expect(response!.status).toBe(200);
expect(response!.contentType).toContain('application/json');
expect(response!.body).not.toContain('notifications/message');
}
});

View file

@ -0,0 +1,178 @@
/**
* Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised
* through the wire() entry arms: `entryStateless` hosts the entry's stateless
* legacy fallback (the default posture) for plain 2025-era clients (2025-11-25
* axis) and `entryModern` hosts the modern-only strict (`legacy: 'reject'`)
* endpoint for negotiating clients (2026-07-28 axis). Raw wire facts (request
* bodies, statuses, response bytes) are asserted on the arm-recorded
* `wired.httpLog`; raw HTTP probes go through `wired.fetch` so every exchange
* still rides the harness-hosted entry.
*/
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { McpServer } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { modernEnvelopeMeta, wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
const LEGACY = '2025-11-25';
const MODERN = '2026-07-28';
/** One ctx-taking factory backing every cell: the era only shows up in the tool output so tests can see which leg served the call. */
function greetFactory(ctx?: McpRequestContext): McpServer {
const server = new McpServer({ name: 'e2e-entry', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({
content: [{ type: 'text', text: `hello ${name} (${ctx?.era ?? 'unknown'})` }]
}));
return server;
}
verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => {
// Both cells host the same handler shape — one ctx-taking factory, the
// 'stateless' legacy posture — driven by a plain client; the entry arm
// decides which era serves it (entryModern pins the client to 2026-07-28).
const client = new Client({ name: 'dual-era-client', version: '1.0.0' });
await using wired = await wire(transport, greetFactory, client, { entry: { legacy: 'stateless' } });
if (transport === 'entryStateless') {
// 2025-era leg: a plain client is served per request through the
// stateless legacy fallback — initialize → tools/list → tools/call.
expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY);
const tools = await client.listTools();
expect(tools.tools.map(tool => tool.name)).toEqual(['greet']);
const result = await client.callTool({ name: 'greet', arguments: { name: 'old friend' } });
expect(result.content).toEqual([{ type: 'text', text: 'hello old friend (legacy)' }]);
return;
}
// 2026-era leg: the arm-pinned client reaches 2026-07-28 via
// server/discover — never initialize — and tools/call is served with the
// per-request envelope (the modern factory leg answers, not the slot).
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? '');
// The "(never initialize)" clause of the requirement, asserted on the
// recorded wire traffic: no request body ever carried an initialize,
// and the negotiation rode server/discover.
expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false);
expect(requestBodies().some(body => body.includes('server/discover'))).toBe(true);
const result = await client.callTool({ name: 'greet', arguments: { name: 'new friend' } });
expect(result.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]);
// ...and still no initialize anywhere on the wire after the tool call —
// the whole conversation rode the modern handshake.
expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false);
});
verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestArgs) => {
// Strict endpoint (legacy: 'reject' — the entryModern arm hosting): the pinned client never needs the legacy leg.
const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await using wired = await wire(transport, greetFactory, client);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
const requestBodies = () => (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? '');
// No initialize was ever put on the wire; the first request is the discover probe.
expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false);
expect(requestBodies()[0]).toContain('server/discover');
const result = await client.callTool({ name: 'greet', arguments: { name: 'pinned' } });
expect(result.content).toEqual([{ type: 'text', text: 'hello pinned (modern)' }]);
// The tool call rode the per-request envelope on the wire...
const callBody = requestBodies().find(body => body.includes('"tools/call"'));
expect(callBody).toBeDefined();
expect(callBody).toContain(PROTOCOL_VERSION_META_KEY);
// ...and still no initialize anywhere on the wire after the tool call.
expect(requestBodies().some(body => body.includes('"initialize"'))).toBe(false);
});
verifies('typescript:client:connect:prior-zero-roundtrip', async ({ transport }: TestArgs) => {
// Bootstrap: the wired negotiating client (the entryModern arm pins it to
// the modern revision) populates getDiscoverResult().
const bootstrap = new Client({ name: 'bootstrap', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await using wired = await wire(transport, greetFactory, bootstrap);
const prior = bootstrap.getDiscoverResult();
expect(prior).toBeDefined();
expect(prior!.supportedVersions).toContain(MODERN);
// Fresh worker → SAME hosted server, connect({ prior }): zero round trips.
const before = wired.httpLog!.length;
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }), { prior });
try {
// No HTTP exchange was added by the worker's connect().
expect(wired.httpLog!.length).toBe(before);
expect(worker.getNegotiatedProtocolVersion()).toBe(MODERN);
// First wire traffic from the worker is the tools/call itself.
const result = await worker.callTool({ name: 'greet', arguments: { name: 'prior' } });
expect(result.content).toEqual([{ type: 'text', text: 'hello prior (modern)' }]);
expect(wired.httpLog!.length).toBe(before + 1);
expect(wired.httpLog![before]!.requestBody).toContain('"tools/call"');
} finally {
await worker.close().catch(() => {});
}
});
verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => {
// legacy: 'reject' → modern-only strict (the entryModern arm hosting): no silent 2025 serving.
const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await using wired = await wire(transport, greetFactory, modernClient);
// The documented strict cell over plain HTTP: a 2025-shaped initialize is
// answered with the unsupported-protocol-version error naming the
// supported modern revisions (the numeric code is not pinned here).
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LEGACY, capabilities: {}, clientInfo: { name: 'plain-2025-client', version: '1.0.0' } }
})
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: { code: number; message: string; data?: { supported?: string[] } } };
expect(body.error.message).toMatch(/unsupported protocol version/i);
expect(body.error.data?.supported).toContain(MODERN);
// The plain SDK client sees the same rejection at connect time.
const plainClient = new Client({ name: 'plain-2025-client', version: '1.0.0' });
try {
await expect(plainClient.connect(new StreamableHTTPClientTransport(wired.url!, { fetch: wired.fetch }))).rejects.toThrow(
/Unsupported protocol version|400/
);
} finally {
await plainClient.close().catch(() => {});
}
});
verifies('typescript:hosting:entry:notification-202', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'notify-client', version: '1.0.0' });
await using wired = await wire(transport, greetFactory, client);
// 2025 leg: an envelope-less notification rides the legacy stateless slot.
// 2026 leg: the notification carries the per-request envelope and a method
// the 2026-07-28 registry defines.
const notification =
transport === 'entryStateless'
? { jsonrpc: '2.0', method: 'notifications/initialized' }
: {
jsonrpc: '2.0',
method: 'notifications/cancelled',
params: {
requestId: 'never-issued',
reason: 'probe',
_meta: modernEnvelopeMeta({ name: 'notify-client', version: '1.0.0' })
}
};
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify(notification)
});
expect(response.status).toBe(202);
expect(await response.text()).toBe('');
});

View file

@ -24,13 +24,16 @@ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/cli
import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { OAuthMetadata } from '@modelcontextprotocol/server';
import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import { LATEST_PROTOCOL_VERSION, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/server-legacy';
import { mcpAuthRouter } from '@modelcontextprotocol/server-legacy';
import type { Express, RequestHandler } from 'express';
import express from 'express';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { startExpressMinimal, startExpressWithHostValidation } from '../helpers/express';
import { defined } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
@ -387,6 +390,70 @@ verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
expect(headerRes.status).toBe(200);
});
verifies('hosting:auth:as-iss-emission', async (_args: TestArgs) => {
// Minimal OAuthServerProvider whose authorize() issues a plain success redirect WITHOUT
// appending `iss` itself — the bundled handler must add it so the metadata claim is true
// without provider action.
const seenIssuer: Array<string | undefined> = [];
const provider: OAuthServerProvider = {
clientsStore: {
// eslint-disable-next-line @typescript-eslint/require-await
async getClient(clientId) {
return clientId === 'demo-client'
? { client_id: 'demo-client', redirect_uris: ['https://client.example.com/cb'] }
: undefined;
}
},
// eslint-disable-next-line @typescript-eslint/require-await
async authorize(_client, params: AuthorizationParams, res) {
seenIssuer.push(params.issuer);
const u = new URL(params.redirectUri);
u.searchParams.set('code', 'demo-auth-code');
if (params.state) u.searchParams.set('state', params.state);
res.redirect(302, u.href);
},
challengeForAuthorizationCode: async () => 'challenge',
exchangeAuthorizationCode: async () => ({ access_token: 't', token_type: 'Bearer' }),
exchangeRefreshToken: async () => ({ access_token: 't', token_type: 'Bearer' }),
verifyAccessToken: async token => ({ token, clientId: 'demo-client', scopes: [] })
};
const app = express();
app.use(mcpAuthRouter({ provider, issuerUrl: new URL('http://localhost/'), authorizationOptions: { rateLimit: false } }));
await using host = await startExpressMinimal(app);
// Metadata advertises RFC 9207 support.
const md = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl));
expect(md.status).toBe(200);
const mdBody = (await md.json()) as { issuer: string; authorization_response_iss_parameter_supported?: boolean };
expect(mdBody.authorization_response_iss_parameter_supported).toBe(true);
// Success redirect: handler supplies issuer to provider.authorize() and appends `iss` to
// the provider's redirect itself (provider did not set it).
const ok = await fetch(
new URL(
'/authorize?client_id=demo-client&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&response_type=code&code_challenge=abc&code_challenge_method=S256&state=xyz',
host.baseUrl
),
{ redirect: 'manual' }
);
expect(ok.status).toBe(302);
const okLoc = new URL(defined(ok.headers.get('location'), 'location'));
expect(okLoc.searchParams.get('code')).toBe('demo-auth-code');
expect(okLoc.searchParams.get('iss')).toBe(mdBody.issuer);
expect(seenIssuer).toEqual([mdBody.issuer]);
// Error redirect (missing code_challenge → invalid_request): handler appends iss itself.
const err = await fetch(
new URL('/authorize?client_id=demo-client&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb&state=xyz', host.baseUrl),
{ redirect: 'manual' }
);
expect(err.status).toBe(302);
const errLoc = new URL(defined(err.headers.get('location'), 'location'));
expect(errLoc.searchParams.get('error')).toBe('invalid_request');
expect(errLoc.searchParams.get('iss')).toBe(mdBody.issuer);
});
/** Listen `app` (already fully configured by the adapter under test) on an ephemeral 127.0.0.1 port; callers close() in finally. */
function listenExpressApp(app: Express): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
@ -475,13 +542,16 @@ verifies('hosting:express:adapter-host-header-validation', async ({ protocolVers
expect(mcpRouteHits).toBe(0);
// Control: the identical request with the real localhost Host reaches the transport and initializes normally.
// The negotiated version follows initialize semantics: a 2026-era request is answered with the latest legacy
// version (2026-era revisions are never negotiated via initialize); legacy requests are echoed back.
const negotiatedVersion = protocolVersion >= '2026-07-28' ? LATEST_PROTOCOL_VERSION : protocolVersion;
const allowed = await postWithHost(new URL('/mcp', baseUrl), `127.0.0.1:${baseUrl.port}`, initializeBody);
expect(allowed.status).toBe(200);
const allowedJson: unknown = JSON.parse(allowed.body);
expect(allowedJson).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: { protocolVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } }
result: { protocolVersion: negotiatedVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } }
});
expect(mcpRouteHits).toBe(1);
} finally {

View file

@ -0,0 +1,596 @@
/**
* Self-contained test bodies for the JSON Schema 2020-12 validator posture
* (SEP-1613 dialect, SEP-2106 non-object roots + legacy `{result:…}` wrap).
*
* Each export is a {@link verifies} body: it builds its own server (via a
* factory), builds its own client, wires them with {@link wire}, and asserts.
* There are no shared fixture imports; helpers local to multiple bodies live at
* the top of this file.
*
* The era-spanning bodies use `type:'object'`-rooted output schemas (so the
* 2025-era wire codec — which keeps `outputSchema`/`structuredContent` at their
* object/Record shapes for byte-identity — round-trips them on every arm). The
* non-object-root bodies are restricted to the createMcpHandler entry arms in
* `requirements.ts` because only the 2026-07-28 wire codec carries that
* vocabulary natively.
*/
import { Client } from '@modelcontextprotocol/client';
import type { Tool } from '@modelcontextprotocol/server';
import { fromJsonSchema, McpServer, ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
/** Plain client with no extra capabilities declared. */
const newClient = () => new Client({ name: 'c', version: '0' });
/** Object-root output schema with a same-document `$ref` into `$defs`. */
const SAME_DOCUMENT_REF_OUTPUT = {
type: 'object' as const,
properties: { point: { $ref: '#/$defs/Point' } },
required: ['point'],
$defs: {
Point: {
type: 'object',
properties: { x: { type: 'number' }, y: { type: 'number' } },
required: ['x', 'y']
}
}
};
/**
* Object-root output schema with an external (network) `$ref`. The SDK does not pre-screen
* `$ref` (the spec MUST-NOT is "do not dereference", not "reject") — the underlying Ajv engine
* does not fetch external refs and throws a `MissingRefError` at compile time, which the client
* captures per-tool and surfaces as `InvalidParams`.
*/
const NETWORK_REF_OUTPUT = {
type: 'object' as const,
properties: { point: { $ref: 'https://schemas.example.invalid/point.json' } },
required: ['point']
};
/** Object-root output schema declaring a `$schema` dialect URI no built-in provider recognises. */
const UNKNOWN_DIALECT_OUTPUT = {
$schema: 'https://example.invalid/json-schema/v99/schema',
type: 'object' as const,
properties: { value: { type: 'number' } },
required: ['value']
};
/**
* Low-level Server factory advertising one tool per fixture output schema.
* The low-level Server applies no server-side output validation, so the
* client-side validator behavior under test is the only check in the path.
*/
function refSchemaServer(): Server {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', () => ({
tools: [
{
name: 'network-ref',
inputSchema: { type: 'object' },
outputSchema: NETWORK_REF_OUTPUT
},
{
name: 'local-ref',
inputSchema: { type: 'object' },
outputSchema: SAME_DOCUMENT_REF_OUTPUT
},
{
name: 'unknown-dialect',
inputSchema: { type: 'object' },
outputSchema: UNKNOWN_DIALECT_OUTPUT
}
]
}));
s.setRequestHandler('tools/call', req => {
switch (req.params.name) {
case 'network-ref':
case 'local-ref': {
return { structuredContent: { point: { x: 1, y: 2 } }, content: [] };
}
case 'unknown-dialect': {
return { structuredContent: { value: 7 }, content: [] };
}
default: {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `unknown tool ${req.params.name}`);
}
}
});
return s;
}
verifies('client:jsonschema:same-document-ref-ok', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, refSchemaServer, client);
const { tools } = await client.listTools();
const tool = tools.find(t => t.name === 'local-ref');
expect(tool?.outputSchema).toMatchObject({ $defs: { Point: { type: 'object' } } });
const r = await client.callTool({ name: 'local-ref', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toEqual({ point: { x: 1, y: 2 } });
});
verifies('client:jsonschema:unsupported-dialect-graceful', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, refSchemaServer, client);
const { tools } = await client.listTools();
expect(tools.find(t => t.name === 'unknown-dialect')?.outputSchema).toMatchObject({
$schema: 'https://example.invalid/json-schema/v99/schema'
});
const call = client.callTool({ name: 'unknown-dialect', arguments: {} });
await expect(call).rejects.toBeInstanceOf(ProtocolError);
const err = await call.catch(error => error as ProtocolError);
expect(err.code).toBe(ProtocolErrorCode.InvalidParams);
expect(err.message).toMatch(/invalid outputSchema.*unsupported dialect/i);
});
verifies('client:jsonschema:bad-schema-isolates-tool', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, refSchemaServer, client);
// The listing carries every tool, including the one whose schema the
// validator engine refuses to compile (external `$ref` → MissingRefError).
const { tools } = await client.listTools();
expect(tools.map(t => t.name).toSorted()).toEqual(['local-ref', 'network-ref', 'unknown-dialect']);
// The good tool is callable and validates.
const ok = await client.callTool({ name: 'local-ref', arguments: {} });
expect(ok.isError).toBeFalsy();
expect(ok.structuredContent).toEqual({ point: { x: 1, y: 2 } });
// The bad tool surfaces its compile failure lazily, per-tool.
const bad = client.callTool({ name: 'network-ref', arguments: {} });
await expect(bad).rejects.toBeInstanceOf(ProtocolError);
const err = await bad.catch(error => error as ProtocolError);
expect(err.code).toBe(ProtocolErrorCode.InvalidParams);
expect(err.message).toMatch(/invalid outputSchema/i);
});
verifies('client:jsonschema:non-object-output', async ({ transport }: TestArgs) => {
// Low-level server with a non-object-root output schema. Only meaningful on
// the 2026-07-28 wire codec (entryModern arm), where outputSchema is a
// loose object and structuredContent is `unknown`.
const makeServer = (): Server => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', () => ({
tools: [
{
name: 'array-out',
inputSchema: { type: 'object' },
outputSchema: { type: 'array', items: { type: 'number' } }
}
]
}));
s.setRequestHandler('tools/call', () => ({ structuredContent: [1, 2, 3], content: [] }));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { tools } = await client.listTools();
expect(tools.find(t => t.name === 'array-out')?.outputSchema).toMatchObject({ type: 'array' });
const r = await client.callTool({ name: 'array-out', arguments: {} });
expect(r.isError).toBeFalsy();
// SEP-2106: structuredContent is typed `unknown`; narrow at the call site.
expect(r.structuredContent).toEqual([1, 2, 3]);
expect(Array.isArray(r.structuredContent)).toBe(true);
});
verifies('client:jsonschema:2020-12:prefixItems', async ({ transport }: TestArgs) => {
// Low-level server advertising a 2020-12-only `prefixItems` outputSchema and
// returning structuredContent in the WRONG positional order. Ajv2020
// enforces prefixItems → validation fails; a draft-07 Ajv with strict:false
// would ignore the keyword and accept. This pins the SEP-1613 default.
const makeServer = (): Server => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', () => ({
tools: [
{
name: 'tuple-out',
inputSchema: { type: 'object' },
outputSchema: {
type: 'array',
prefixItems: [{ type: 'number' }, { type: 'string' }]
}
}
]
}));
s.setRequestHandler('tools/call', () => ({ structuredContent: ['x', 1], content: [] }));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
await client.listTools();
const call = client.callTool({ name: 'tuple-out', arguments: {} });
await expect(call).rejects.toBeInstanceOf(ProtocolError);
const err = await call.catch(error => error as ProtocolError);
expect(err.code).toBe(ProtocolErrorCode.InvalidParams);
expect(err.message).toMatch(/output schema/i);
});
/**
* Low-level Server advertising a `prefixItems` outputSchema with no `$schema`
* stamp. The handler returns structuredContent that violates `prefixItems`
* (positions swapped). With the 2020-12 default, `prefixItems` is enforced and
* validation fails.
*/
function dialectServer(): Server {
const out: Tool['outputSchema'] = {
type: 'object',
properties: { v: { type: 'array', prefixItems: [{ type: 'number' }, { type: 'string' }] } },
required: ['v']
};
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', () => ({
tools: [{ name: 'no-stamp', inputSchema: { type: 'object' }, outputSchema: out }]
}));
s.setRequestHandler('tools/call', () => ({ structuredContent: { v: ['x', 1] }, content: [] }));
return s;
}
verifies('client:jsonschema:dialect:default-is-2020-12', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, dialectServer, client);
await client.listTools();
// No `$schema` → 2020-12 default → `prefixItems` enforced → {v:['x',1]} invalid.
const call = client.callTool({ name: 'no-stamp', arguments: {} });
await expect(call).rejects.toBeInstanceOf(ProtocolError);
const err = await call.catch(error => error as ProtocolError);
expect(err.code).toBe(ProtocolErrorCode.InvalidParams);
expect(err.message).toMatch(/output schema/i);
});
verifies('client:jsonschema:falsy-structured-content-validated', async ({ transport }: TestArgs) => {
// Low-level server with `outputSchema:{type:'integer'}` returning
// `structuredContent: 0`. Pins the SEP-2106 §4.3 `=== undefined` presence
// check on the client: a falsy value is treated as PRESENT and validated,
// not as missing.
const makeServer = (): Server => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', () => ({
tools: [
{
name: 'zero',
inputSchema: { type: 'object' },
outputSchema: { type: 'integer' }
}
]
}));
s.setRequestHandler('tools/call', () => ({ structuredContent: 0, content: [] }));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
await client.listTools();
const r = await client.callTool({ name: 'zero', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toBe(0);
expect(r.structuredContent === 0).toBe(true);
});
verifies('server:jsonschema:array-structured-content-textfallback', async ({ transport }: TestArgs) => {
const makeServer = (): McpServer => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'list-numbers',
{
inputSchema: z.object({}),
outputSchema: fromJsonSchema<number[]>({ type: 'array', items: { type: 'number' } })
},
() => ({ structuredContent: [1, 2, 3], content: [] })
);
s.registerTool(
'list-authored',
{
inputSchema: z.object({}),
outputSchema: fromJsonSchema<number[]>({ type: 'array', items: { type: 'number' } })
},
() => ({ structuredContent: [1], content: [{ type: 'text', text: 'mine' }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'list-numbers', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toEqual([1, 2, 3]);
// The auto-TextContent fallback carries the JSON serialisation because
// the handler authored no `type:'text'` block of its own.
expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) });
// Author opt-out: any author-supplied `type:'text'` block suppresses the
// auto-fallback — exactly the authored block, no JSON-stringify append.
const own = await client.callTool({ name: 'list-authored', arguments: {} });
expect(own.structuredContent).toEqual([1]);
const textBlocks = (own.content ?? []).filter(c => c.type === 'text');
expect(textBlocks).toEqual([{ type: 'text', text: 'mine' }]);
});
verifies('server:jsonschema:primitive-structured-content', async ({ transport }: TestArgs) => {
const makeServer = (): McpServer => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'count',
{
inputSchema: z.object({}),
outputSchema: fromJsonSchema<number>({ type: 'number' })
},
() => ({ structuredContent: 0, content: [] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'count', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toBe(0);
expect(r.content).toContainEqual({ type: 'text', text: '0' });
});
verifies(
['2025:jsonschema:non-object-output-wrapped', '2025:jsonschema:non-object-structured-content-wrapped'],
async ({ transport }: TestArgs) => {
// McpServer with a non-object-root outputSchema, served on the 2025 era
// (entryStateless arm). The legacy interop wraps the outputSchema in a
// `{type:'object',properties:{result:…},required:['result']}` envelope so
// 2025 clients can parse it, and wraps the structuredContent as
// `{result: <value>}` so it satisfies the envelope. The auto-TextContent
// fallback also carries the natural JSON serialisation.
const makeServer = (): McpServer => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'list-numbers',
{
inputSchema: z.object({}),
outputSchema: fromJsonSchema<number[]>({ type: 'array', items: { type: 'number' } })
},
() => ({ structuredContent: [1, 2, 3], content: [] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { tools } = await client.listTools();
const tool = tools.find(t => t.name === 'list-numbers');
expect(tool).toBeDefined();
// The non-object outputSchema is wrapped in the {result:…} envelope on the legacy projection.
expect(tool?.outputSchema).toMatchObject({
type: 'object',
properties: { result: { type: 'array', items: { type: 'number' } } },
required: ['result']
});
// The tool stays callable on the legacy era: structuredContent is wrapped as
// {result:[1,2,3]} so it satisfies both the 2025 wire shape (object-only) and the
// wrapped outputSchema; the auto-TextContent fallback carries the natural value.
const r = await client.callTool({ name: 'list-numbers', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toEqual({ result: [1, 2, 3] });
expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) });
}
);
/**
* McpServer with a typeless-root outputSchema (`anyOf:[object, string]` from `z.union`). The
* legacy wrap predicate is per-tool and follows the SCHEMA root (which is non-object → wraps),
* not the runtime value's shape — so on the 2025 era both the object branch `{a:1}` and the
* string branch `"x"` come back wrapped as `structuredContent.result`, and on the 2026 era both
* come back natural.
*/
function unionOutputServer(): McpServer {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'union-out',
{
inputSchema: z.object({ which: z.enum(['obj', 'str']) }),
outputSchema: z.union([z.object({ a: z.number() }), z.string()])
},
({ which }) => ({ structuredContent: which === 'obj' ? { a: 1 } : 'x', content: [] })
);
return s;
}
verifies('2025:jsonschema:wrap-follows-schema-not-value', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, unionOutputServer, client);
const { tools } = await client.listTools();
const wrapped = tools.find(t => t.name === 'union-out')?.outputSchema;
// The typeless `{anyOf:[…]}` root is wrapped in the {result:…} envelope on the legacy projection.
expect(wrapped).toMatchObject({
type: 'object',
properties: { result: { anyOf: [{ type: 'object' }, { type: 'string' }] } },
required: ['result']
});
// BOTH branches — including the object-valued one — are wrapped as {result:…} so the
// result satisfies the wrapped schema (and the legacy client validates it).
const obj = await client.callTool({ name: 'union-out', arguments: { which: 'obj' } });
expect(obj.isError).toBeFalsy();
expect(obj.structuredContent).toEqual({ result: { a: 1 } });
const str = await client.callTool({ name: 'union-out', arguments: { which: 'str' } });
expect(str.isError).toBeFalsy();
expect(str.structuredContent).toEqual({ result: 'x' });
// The string branch is a non-object value, so the era-agnostic auto-TextContent fires.
expect(str.content).toContainEqual({ type: 'text', text: '"x"' });
});
verifies('server:jsonschema:union-output-natural', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, unionOutputServer, client);
const { tools } = await client.listTools();
// No wrap on the 2026 era — the natural typeless `{anyOf:[…]}` root is advertised.
expect(tools.find(t => t.name === 'union-out')?.outputSchema).toMatchObject({
anyOf: [{ type: 'object' }, { type: 'string' }]
});
const obj = await client.callTool({ name: 'union-out', arguments: { which: 'obj' } });
expect(obj.isError).toBeFalsy();
expect(obj.structuredContent).toEqual({ a: 1 });
const str = await client.callTool({ name: 'union-out', arguments: { which: 'str' } });
expect(str.isError).toBeFalsy();
expect(str.structuredContent).toBe('x');
// The auto-TextContent fallback applies on EVERY era for non-object values.
expect(str.content).toContainEqual({ type: 'text', text: '"x"' });
});
verifies('2025:jsonschema:schemaless-non-object-sc-wrapped', async ({ transport }: TestArgs) => {
// Low-level Server with NO advertised outputSchema, returning a non-object
// structuredContent. The 2025 wire shape requires structuredContent to be an
// object — `server.projectCallToolResult` wraps on value shape alone so the
// result is wire-legal even with nothing to consult on the schema side.
const makeServer = (): Server => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', () => ({
tools: [{ name: 'schemaless', inputSchema: { type: 'object' } }]
}));
s.setRequestHandler('tools/call', () => s.projectCallToolResult({ content: [], structuredContent: [1, 2, 3] }, undefined));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
await client.listTools();
const r = await client.callTool({ name: 'schemaless', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toEqual({ result: [1, 2, 3] });
expect(r.content).toContainEqual({ type: 'text', text: JSON.stringify([1, 2, 3]) });
});
verifies('2025:jsonschema:ref-rewrite-on-wrap', async ({ transport }: TestArgs) => {
// A non-object outputSchema with a same-document `$ref` (e.g. a recursive array). On the
// legacy era the schema is wrapped under `#/properties/result`, so the `$ref` JSON Pointer
// must be rewritten to keep resolving (`#/items` → `#/properties/result/items`). Mirrors the
// C# SDK's TransformOutputSchemaForLegacyWire.
const NATURAL = {
type: 'array',
items: { anyOf: [{ type: 'number' }, { $ref: '#' }] }
} as const;
const makeServer = (): McpServer => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('tree', { inputSchema: z.object({}), outputSchema: fromJsonSchema<unknown[]>(NATURAL) }, () => ({
structuredContent: [1, [2, 3]],
content: []
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { tools } = await client.listTools();
const wrapped = tools.find(t => t.name === 'tree')?.outputSchema;
expect(wrapped).toMatchObject({
type: 'object',
properties: { result: { type: 'array', items: { anyOf: [{ type: 'number' }, { $ref: '#/properties/result' }] } } },
required: ['result']
});
// The wrapped schema compiles on the client (the rewritten `$ref` resolves) and validates the
// wrapped structuredContent.
const r = await client.callTool({ name: 'tree', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.structuredContent).toEqual({ result: [1, [2, 3]] });
});
verifies('2025:jsonschema:ref-rewrite-scope', async ({ transport }: TestArgs) => {
// The legacy-wrap `$ref` rewrite is position-aware: it applies to `$ref`/`$dynamicRef` in
// subschema positions, but NOT to keyword-position data (`const`/`enum`/`default`/`examples`)
// where a `{$ref:…}` is a literal value. A property NAMED `default`/`const` under
// `properties`/`$defs` is a NAME position whose value IS a subschema — recursed into. The
// rewrite is also `$id`-scoped: a natural schema carrying `$id` keeps its same-document refs
// unrewritten (they resolve against the embedded base, not the wrapper root).
//
// Listing-only assertion: Ajv2020 stack-overflows when the compiled validator for a
// `$dynamicRef` with a JSON-Pointer fragment (rather than a `$dynamicAnchor`) is RUN — compile
// succeeds (fromJsonSchema below calls it eagerly), validation does not — so the tool is
// intentionally never called; the rewrite contract is about the wrapped SCHEMA in tools/list.
const NATURAL = {
anyOf: [{ $dynamicRef: '#/$defs/X' }, { const: { $ref: '#/foo' } }],
$defs: {
X: {
type: 'object',
// The OUTER `default` here is a property NAME under `properties` — its value is a
// subschema in keyword position, so the `$ref` inside the subschema is rewritten;
// the INNER `default`/`examples` are keywords whose values are instance data.
properties: { default: { $ref: '#/$defs/X', default: { $ref: '#' }, examples: [{ $ref: '#/bar' }] } },
required: ['default']
}
}
} as const;
const WITH_ID = {
$id: 'https://example/x',
type: 'array',
items: { $ref: '#/$defs/D' },
$defs: { D: { type: 'number' } }
} as const;
const makeServer = (): McpServer => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('scope', { inputSchema: z.object({}), outputSchema: fromJsonSchema<unknown>(NATURAL) }, () => ({
structuredContent: { default: { default: 7 } },
content: []
}));
s.registerTool('with-id', { inputSchema: z.object({}), outputSchema: fromJsonSchema<unknown>(WITH_ID) }, () => ({
structuredContent: [1],
content: []
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { tools } = await client.listTools();
const wrapped = tools.find(t => t.name === 'scope')?.outputSchema;
expect(wrapped).toEqual({
type: 'object',
properties: {
result: {
anyOf: [
{ $dynamicRef: '#/properties/result/$defs/X' }, // keyword position — rewritten
{ const: { $ref: '#/foo' } } // keyword data position — NOT rewritten
],
$defs: {
X: {
type: 'object',
properties: {
default: {
// name-position `default` recursed into; its `$ref` rewritten
$ref: '#/properties/result/$defs/X',
default: { $ref: '#' }, // keyword data position — NOT rewritten
examples: [{ $ref: '#/bar' }] // keyword data position — NOT rewritten
}
},
required: ['default']
}
}
}
},
required: ['result']
});
// `$id` at the natural root: same-document refs resolve against the embedded base, so the
// embedded schema is wrapped but its `$ref` is NOT rewritten.
expect(tools.find(t => t.name === 'with-id')?.outputSchema).toEqual({
type: 'object',
properties: { result: WITH_ID },
required: ['result']
});
});

View file

@ -0,0 +1,510 @@
/**
* Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) through
* the public surface: a write-once tool returning inputRequired() is
* fulfilled by the client's registered elicitation handler and retried with
* fresh ids + a byte-exact requestState echo; push-style server→client APIs
* loud-fail on 2026-era requests with the inputRequired() steer; URL-mode
* elicitation rides the flow with zero -32042 on the 2026 wire; the
* auto-fulfilment driver is bounded by inputRequired.maxRounds; and 2025-era
* serving keeps the exact -32042 behavior (the freeze cell).
*
* The 2026-era cells run on the entryModern arm (per-request modern hosting);
* raw wire facts are asserted on the arm-recorded HTTP exchanges.
*/
import { Client, SdkError, SdkErrorCode } from '@modelcontextprotocol/client';
import { acceptedContent, inputRequired, McpServer, ProtocolError, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import type { Wired } from '../helpers/index';
import { wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
/** Every JSON-RPC request the wired client POSTed for the given method, in order. */
function recordedRequests(wired: Wired, method: string): Array<Record<string, unknown>> {
const requests: Array<Record<string, unknown>> = [];
for (const exchange of wired.httpLog ?? []) {
if (exchange.requestBody === undefined) continue;
try {
const parsed = JSON.parse(exchange.requestBody) as Record<string, unknown>;
if (parsed.method === method) requests.push(parsed);
} catch {
// Not a JSON body (e.g. an empty notification POST) — skip it.
}
}
return requests;
}
/** All recorded HTTP bytes (request bodies + response bodies) concatenated, for absence assertions. */
async function allRecordedBytes(wired: Wired): Promise<string> {
const responses = await Promise.all((wired.httpLog ?? []).map(exchange => exchange.response.text()));
const requests = (wired.httpLog ?? []).map(exchange => exchange.requestBody ?? '');
return [...requests, ...responses].join('\n');
}
const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] };
verifies('typescript:mrtr:tools-call:write-once-roundtrip', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => {
const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
if (!confirmed?.confirm) {
return inputRequired({
inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) },
requestState: 'opaque-deploy-state'
});
}
return { content: [{ type: 'text', text: `deployed to ${env}` }] };
});
return server;
};
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
const handled: unknown[] = [];
client.setRequestHandler('elicitation/create', async request => {
handled.push(request.params);
return { action: 'accept', content: { confirm: true } };
});
await using wired = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'deploy', arguments: { env: 'prod' } });
expect(result.content).toEqual([{ type: 'text', text: 'deployed to prod' }]);
expect('resultType' in result).toBe(false);
// The registered handler fulfilled the embedded elicitation.
expect(handled).toHaveLength(1);
expect(handled[0]).toMatchObject({ mode: 'form', message: 'Deploy to prod?' });
// Two independent wire legs with fresh ids; the retry carries the bare
// response and the byte-exact requestState echo alongside the original params.
const toolCalls = recordedRequests(wired, 'tools/call');
expect(toolCalls).toHaveLength(2);
expect(toolCalls[0]!.id).not.toEqual(toolCalls[1]!.id);
const retryParams = toolCalls[1]!.params as Record<string, unknown>;
expect(retryParams.name).toBe('deploy');
expect(retryParams.arguments).toEqual({ env: 'prod' });
expect(retryParams.requestState).toBe('opaque-deploy-state');
expect(retryParams.inputResponses).toEqual({ confirm: { action: 'accept', content: { confirm: true } } });
});
verifies('typescript:mrtr:push-api:loud-fail-2026', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('legacy-style', { inputSchema: z.object({}) }, async (_args, ctx) => {
// The pre-2026 pattern: pushing a server→client elicitation request.
const answer = await ctx.mcpReq.elicitInput({ message: 'Name?', requestedSchema: { type: 'object', properties: {} } });
return { content: [{ type: 'text', text: JSON.stringify(answer) }] };
});
return server;
};
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: {} }));
await using wired = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'legacy-style', arguments: {} });
expect(result.isError).toBe(true);
expect(JSON.stringify(result.content)).toContain('inputRequired(');
// The attempted server→client request never produced wire traffic: no
// elicitation/create request appears in any recorded exchange.
const bytes = await allRecordedBytes(wired);
expect(bytes).not.toContain('"method":"elicitation/create"');
});
verifies('typescript:mrtr:url-elicitation:no-32042-on-2026', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('protected', { inputSchema: z.object({}) }, async (_args, ctx) => {
if (ctx.mcpReq.inputResponses?.['auth'] !== undefined) {
return { content: [{ type: 'text', text: 'authorized' }] };
}
// The 2026-07-28 idiom: return an embedded URL-mode elicitation
// (the 2025-style throw is not converted on this era).
return inputRequired({
inputRequests: {
auth: inputRequired.elicitUrl({
message: 'Sign in to continue',
url: 'https://example.com/auth'
})
}
});
});
return server;
};
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } });
const seenUrlRequests: unknown[] = [];
client.setRequestHandler('elicitation/create', async request => {
seenUrlRequests.push(request.params);
// URL mode: the user completes the interaction out of band; the
// response carries no content.
return { action: 'accept' };
});
await using wired = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'protected', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'authorized' }]);
expect(seenUrlRequests).toHaveLength(1);
expect(seenUrlRequests[0]).toMatchObject({ mode: 'url', url: 'https://example.com/auth' });
// The -32042 error code never appears on the 2026 wire; the
// input_required result is what travelled instead.
const bytes = await allRecordedBytes(wired);
expect(bytes).not.toContain('32042');
expect(bytes).toContain('"resultType":"input_required"');
});
verifies('typescript:mrtr:rounds-cap', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('insatiable', { inputSchema: z.object({}) }, async () =>
inputRequired({
inputRequests: { more: inputRequired.elicit({ message: 'More input?', requestedSchema: CONFIRM_SCHEMA }) },
requestState: 'never-enough'
})
);
return server;
};
const client = new Client(
{ name: 'mrtr-client', version: '1.0.0' },
{ capabilities: { elicitation: { form: {} } }, inputRequired: { maxRounds: 2 } }
);
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { confirm: true } }));
await using wired = await wire(transport, makeServer, client);
const outcome = await client.callTool({ name: 'insatiable', arguments: {} }).then(
value => ({ resolved: value as unknown }),
error => ({ rejected: error as unknown })
);
expect('rejected' in outcome, 'the call must not resolve').toBe(true);
const rejection = (outcome as { rejected: unknown }).rejected;
expect(rejection).toBeInstanceOf(SdkError);
expect((rejection as SdkError).code).toBe(SdkErrorCode.InputRequiredRoundsExceeded);
expect((rejection as SdkError).data).toMatchObject({ rounds: 2, lastResult: { requestState: 'never-enough' } });
// The cap bounded the wire traffic: the original call plus exactly two retries.
expect(recordedRequests(wired, 'tools/call')).toHaveLength(3);
});
// 2026-era siblings of the push-style sampling/elicitation/roots round-trips:
// each body returns inputRequired() with an embedded request, the client
// auto-fulfilment driver dispatches it to the locally registered handler, and
// the retried tool handler reads the response from ctx.mcpReq.inputResponses.
verifies('sampling:mrtr:create:basic', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-sampling', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('summarize', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx) => {
const completion = ctx.mcpReq.inputResponses?.['llm'] as
| { role: string; content: { type: string; text: string }; model: string; stopReason: string }
| undefined;
if (completion === undefined) {
return inputRequired({
inputRequests: {
llm: inputRequired.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }],
maxTokens: 64
})
}
});
}
return { structuredContent: { completion }, content: [{ type: 'text', text: completion.content.text }] };
});
return server;
};
const seen: unknown[] = [];
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { sampling: {} } });
client.setRequestHandler('sampling/createMessage', async request => {
seen.push(request.params);
return { role: 'assistant', content: { type: 'text', text: 'a brief summary' }, model: 'stub-model', stopReason: 'endTurn' };
});
await using wired = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'summarize', arguments: { text: 'hello world' } });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toEqual({
completion: { role: 'assistant', content: { type: 'text', text: 'a brief summary' }, model: 'stub-model', stopReason: 'endTurn' }
});
// The embedded request reached the registered handler exactly once.
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ messages: [{ role: 'user', content: { type: 'text', text: 'Summarize: hello world' } }] });
// Two independent wire legs: original + retry carrying the bare response.
const toolCalls = recordedRequests(wired, 'tools/call');
expect(toolCalls).toHaveLength(2);
const retryParams = toolCalls[1]!.params as Record<string, unknown>;
expect(retryParams.inputResponses).toMatchObject({
llm: { role: 'assistant', content: { type: 'text', text: 'a brief summary' }, model: 'stub-model', stopReason: 'endTurn' }
});
});
verifies(
['sampling:mrtr:create:model-preferences', 'sampling:mrtr:create:system-prompt', 'sampling:mrtr:create:include-context'],
async ({ transport }: TestArgs) => {
const PREFS = { hints: [{ name: 'stub-model' }], costPriority: 0.2, speedPriority: 0.5, intelligencePriority: 0.9 };
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-sampling', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => {
if (ctx.mcpReq.inputResponses?.['llm'] !== undefined) {
return { content: [{ type: 'text', text: 'ok' }] };
}
return inputRequired({
inputRequests: {
llm: inputRequired.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }],
maxTokens: 16,
systemPrompt: 'You are a terse assistant.',
includeContext: 'none',
modelPreferences: PREFS
})
}
});
});
return server;
};
const seen: Array<Record<string, unknown>> = [];
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { sampling: {} } });
client.setRequestHandler('sampling/createMessage', async request => {
seen.push(request.params as Record<string, unknown>);
return { role: 'assistant', content: { type: 'text', text: 'hi' }, model: 'stub-model', stopReason: 'endTurn' };
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'ask', arguments: {} });
expect(result.isError).toBeFalsy();
expect(seen).toHaveLength(1);
expect(seen[0]?.systemPrompt).toBe('You are a terse assistant.');
expect(seen[0]?.includeContext).toBe('none');
expect(seen[0]?.modelPreferences).toEqual(PREFS);
}
);
verifies('elicitation:mrtr:form:basic', async ({ transport }: TestArgs) => {
const SCHEMA = {
type: 'object' as const,
properties: { name: { type: 'string' as const, description: 'Your name' } },
required: ['name']
};
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('greet', { inputSchema: z.object({}) }, async (_args, ctx) => {
const answered = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'who');
if (answered === undefined) {
return inputRequired({
inputRequests: { who: inputRequired.elicit({ message: 'What is your name?', requestedSchema: SCHEMA }) }
});
}
return { content: [{ type: 'text', text: `hello ${answered.name}` }] };
});
return server;
};
const seen: unknown[] = [];
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler('elicitation/create', async request => {
seen.push(request.params);
return { action: 'accept', content: { name: 'Ada' } };
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'greet', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'hello Ada' }]);
// The embedded request delivered message + schema exactly as sent.
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ mode: 'form', message: 'What is your name?', requestedSchema: SCHEMA });
});
verifies('elicitation:mrtr:form:action:decline', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => {
const responses = ctx.mcpReq.inputResponses;
if (responses === undefined) {
return inputRequired({
inputRequests: { confirm: inputRequired.elicit({ message: 'Proceed?', requestedSchema: CONFIRM_SCHEMA }) }
});
}
const raw = responses['confirm'] as { action: string; content?: unknown };
return {
structuredContent: { action: raw.action, accepted: acceptedContent(responses, 'confirm') !== undefined },
content: []
};
});
return server;
};
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' }));
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'ask', arguments: {} });
expect(result.structuredContent).toEqual({ action: 'decline', accepted: false });
});
verifies('elicitation:mrtr:form:action:cancel', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => {
const responses = ctx.mcpReq.inputResponses;
if (responses === undefined) {
return inputRequired({
inputRequests: { confirm: inputRequired.elicit({ message: 'Proceed?', requestedSchema: CONFIRM_SCHEMA }) }
});
}
const raw = responses['confirm'] as { action: string; content?: unknown };
return {
structuredContent: { action: raw.action, accepted: acceptedContent(responses, 'confirm') !== undefined },
content: []
};
});
return server;
};
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler('elicitation/create', async () => ({ action: 'cancel' }));
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'ask', arguments: {} });
expect(result.structuredContent).toEqual({ action: 'cancel', accepted: false });
});
verifies('elicitation:mrtr:form:schema:primitives', async ({ transport }: TestArgs) => {
const SCHEMA = {
type: 'object' as const,
properties: {
email: { type: 'string' as const, format: 'email' as const },
age: { type: 'integer' as const },
score: { type: 'number' as const },
subscribe: { type: 'boolean' as const }
},
required: ['email']
};
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-elicit', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('signup', { inputSchema: z.object({}) }, async (_args, ctx) => {
if (ctx.mcpReq.inputResponses?.['form'] !== undefined) {
return { content: [{ type: 'text', text: 'ok' }] };
}
return inputRequired({
inputRequests: { form: inputRequired.elicit({ message: 'Sign up', requestedSchema: SCHEMA }) }
});
});
return server;
};
const seen: unknown[] = [];
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler('elicitation/create', async request => {
seen.push(request.params);
return { action: 'accept', content: { email: 'ada@example.com', age: 36, score: 0.9, subscribe: true } };
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'signup', arguments: {} });
expect(result.isError).toBeFalsy();
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ requestedSchema: SCHEMA });
});
verifies('roots:mrtr:list:basic', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-roots', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('list-roots', { inputSchema: z.object({}) }, async (_args, ctx) => {
const answered = ctx.mcpReq.inputResponses?.['roots'] as { roots: Array<{ uri: string; name?: string }> } | undefined;
if (answered === undefined) {
return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } });
}
return { structuredContent: { roots: answered.roots }, content: [] };
});
return server;
};
const seen: Array<{ method: string }> = [];
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { roots: {} } });
client.setRequestHandler('roots/list', async request => {
seen.push({ method: request.method });
return {
roots: [{ uri: 'file:///home/user/projects/myproject', name: 'My Project' }, { uri: 'file:///home/user/repos/backend' }]
};
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'list-roots', arguments: {} });
expect(result.isError).toBeFalsy();
expect(seen).toHaveLength(1);
expect(seen[0]?.method).toBe('roots/list');
expect(result.structuredContent).toEqual({
roots: [{ uri: 'file:///home/user/projects/myproject', name: 'My Project' }, { uri: 'file:///home/user/repos/backend' }]
});
});
verifies('roots:mrtr:list:empty', async ({ transport }: TestArgs) => {
const makeServer = () => {
const server = new McpServer({ name: 'mrtr-roots', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('list-roots', { inputSchema: z.object({}) }, async (_args, ctx) => {
const answered = ctx.mcpReq.inputResponses?.['roots'] as { roots: unknown[] } | undefined;
if (answered === undefined) {
return inputRequired({ inputRequests: { roots: inputRequired.listRoots() } });
}
return { structuredContent: { count: answered.roots.length }, content: [] };
});
return server;
};
const client = new Client({ name: 'mrtr-client', version: '1.0.0' }, { capabilities: { roots: {} } });
client.setRequestHandler('roots/list', async () => ({ roots: [] }));
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'list-roots', arguments: {} });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toEqual({ count: 0 });
});
verifies('typescript:mrtr:legacy-32042-freeze', async ({ transport }: TestArgs) => {
const URL_PARAMS = {
mode: 'url' as const,
message: 'Sign in to continue',
elicitationId: 'auth-legacy',
url: 'https://example.com/auth'
};
const makeServer = () => {
const server = new McpServer({ name: 'legacy-url-server', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('protected', { inputSchema: z.object({}) }, async () => {
throw new UrlElicitationRequiredError([URL_PARAMS]);
});
return server;
};
const client = new Client({ name: 'legacy-url-client', version: '1.0.0' }, { capabilities: { elicitation: { url: {} } } });
await using _ = await wire(transport, makeServer, client);
const outcome = await client.callTool({ name: 'protected', arguments: {} }).then(
value => ({ resolved: value as unknown }),
error => ({ rejected: error as unknown })
);
expect('rejected' in outcome, 'the -32042 error must surface, not a result').toBe(true);
const rejection = (outcome as { rejected: unknown }).rejected;
expect(rejection).toBeInstanceOf(ProtocolError);
expect((rejection as ProtocolError).code).toBe(-32_042);
expect((rejection as ProtocolError).data).toEqual({ elicitations: [URL_PARAMS] });
});

View file

@ -95,19 +95,20 @@ verifies('pagination:client:cursor-handling', async ({ transport }: TestArgs) =>
await using _ = await wire(transport, makeServer, client);
const tap = tapWire(client);
const collectedPages: string[][] = [];
let result = await client.listTools();
collectedPages.push(result.tools.map(t => t.name));
while (result.nextCursor !== undefined) {
// A run-away loop means the test fixture, not the SDK, is broken — fail fast instead of hitting the suite timeout.
if (collectedPages.length >= pages.size) throw new Error('nextCursor still present after the last page');
result = await client.listTools({ cursor: result.nextCursor });
collectedPages.push(result.tools.map(t => t.name));
}
// No-arg listTools() auto-aggregates; the client walks the cursor chain on the wire.
const result = await client.listTools();
expect(result.nextCursor).toBeUndefined();
expect(result.tools.map(t => t.name)).toEqual([
'get_weather',
'get_forecast',
'get_alerts',
'convert_units',
'list_stations',
'get_station'
]);
// The handler got back exactly the cursors it issued, and every page arrived once, in order.
// The handler got back exactly the cursors it issued, once each, in order.
expect(receivedCursors).toEqual([undefined, cursorToPage2, cursorToPage3]);
expect(collectedPages).toEqual([['get_weather', 'get_forecast', 'get_alerts'], ['convert_units'], ['list_stations', 'get_station']]);
// The wire requests carried the server-issued strings byte-for-byte — opaque, unparsed, unmodified.
const wireListRequests = tap.sent.filter(m => isJSONRPCRequest(m)).filter(m => m.method === 'tools/list');

View file

@ -128,21 +128,11 @@ verifies(
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const first = await client.listPrompts();
expect(first.prompts.length).toBeLessThan(TOTAL);
expect(first.nextCursor).toBeDefined();
const seen = new Set(first.prompts.map(p => p.name));
let result = first;
let pages = 1;
while (result.nextCursor !== undefined) {
result = await client.listPrompts({ cursor: result.nextCursor });
for (const p of result.prompts) seen.add(p.name);
pages++;
expect(pages).toBeLessThan(50);
}
expect(seen.size).toBe(TOTAL);
expect(pages).toBeGreaterThan(1);
// No-arg listPrompts() auto-aggregates every page.
const all = await client.listPrompts();
expect(all.prompts.length).toBe(TOTAL);
expect(all.nextCursor).toBeUndefined();
expect(new Set(all.prompts.map(p => p.name)).size).toBe(TOTAL);
},
{ title: 'mcpserver' }
);
@ -174,29 +164,20 @@ verifies(
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const seen = new Set<string>();
const cursorsSent: string[] = [];
let pages = 0;
let result = await client.listPrompts();
expect(result.nextCursor).toBeDefined();
for (;;) {
for (const p of result.prompts) {
expect(seen.has(p.name)).toBe(false);
seen.add(p.name);
}
pages++;
if (result.nextCursor === undefined) break;
cursorsSent.push(result.nextCursor);
result = await client.listPrompts({ cursor: result.nextCursor });
expect(pages).toBeLessThan(50);
}
expect(pages).toBe(3);
// No-arg listPrompts() auto-aggregates every page; the server receives
// the cursor walk verbatim (protocol-level pagination is what is
// verified here).
const result = await client.listPrompts();
expect(result.nextCursor).toBeUndefined();
const seen = new Set(result.prompts.map(p => p.name));
expect(seen.size).toBe(TOTAL);
for (const name of all) expect(seen.has(name)).toBe(true);
expect(cursorsReceived).toEqual([undefined, '10', '20']);
expect(cursorsSent).toEqual(['10', '20']);
// Explicit cursor → one raw page (per-page path).
const page = await client.listPrompts({ cursor: '10' });
expect(page.prompts.length).toBe(PAGE);
expect(page.nextCursor).toBe('20');
},
{ title: 'raw server' }
);

View file

@ -18,7 +18,8 @@ import type {
Progress,
RequestId,
Result,
Transport
Transport,
TransportSendOptions
} from '@modelcontextprotocol/server';
import {
InMemoryTransport,
@ -127,6 +128,62 @@ verifies('protocol:cancel:abort-signal', async ({ transport }: TestArgs) => {
expect(cancelled.params?.reason).toContain('user requested cancellation');
});
verifies('protocol:cancel:http-stream-close', async ({ transport }: TestArgs) => {
// 2026-07-28 Streamable HTTP: closing the per-request SSE stream IS the
// cancel signal — no notifications/cancelled is sent on the wire. The body
// proves both the caller-signal and timeout paths route to stream-close.
const client = newClient();
await using _ = await wire(transport, neverRespondingServer, client);
// Tap send to record outbound messages AND the per-request requestSignal
// the protocol layer hands the transport.
const sent: Array<{ m: JSONRPCMessage; opts: TransportSendOptions | undefined }> = [];
const tx = client.transport;
if (!tx) throw new Error('client not connected');
expect(tx.hasPerRequestStream).toBe(true);
const orig = tx.send.bind(tx);
tx.send = async (m, opts) => {
sent.push({ m, opts });
return orig(m, opts);
};
// Caller-signal abort.
const ac = new AbortController();
const call = client.listTools(undefined, { signal: ac.signal });
await vi.waitFor(() => expect(sent.some(s => isRequest(s.m) && s.m.method === 'tools/list')).toBe(true));
const listSend = sent.find(s => isRequest(s.m) && s.m.method === 'tools/list');
if (!listSend) throw new Error('tools/list send not captured');
expect(listSend.opts?.requestSignal, 'protocol layer must thread a per-request requestSignal on a 2026 HTTP connection').toBeInstanceOf(
AbortSignal
);
expect(listSend.opts?.requestSignal?.aborted).toBe(false);
ac.abort('user requested cancellation');
await expect(call).rejects.toThrow(/user requested cancellation/);
expect(listSend.opts?.requestSignal?.aborted, 'stream-close IS the cancel signal: requestSignal must be aborted').toBe(true);
expect(
sent.filter(s => isNotification(s.m) && s.m.method === 'notifications/cancelled'),
'no notifications/cancelled on the wire — "not required or expected" per spec'
).toHaveLength(0);
// Timeout path.
sent.length = 0;
vi.useFakeTimers();
try {
const pending = client.listTools(undefined, { timeout: 100 });
pending.catch(() => {});
await vi.advanceTimersByTimeAsync(100);
await expect(pending).rejects.toMatchObject({ code: SdkErrorCode.RequestTimeout });
} finally {
vi.useRealTimers();
}
const timedOutSend = sent.find(s => isRequest(s.m) && s.m.method === 'tools/list');
if (!timedOutSend) throw new Error('timeout-path tools/list send not captured');
expect(timedOutSend.opts?.requestSignal?.aborted).toBe(true);
expect(sent.filter(s => isNotification(s.m) && s.m.method === 'notifications/cancelled')).toHaveLength(0);
});
verifies('protocol:cancel:handler-abort-propagates', async ({ transport }: TestArgs) => {
const aborts: Array<{ requestId: RequestId; reason: unknown }> = [];
const makeServer = () => {
@ -365,8 +422,11 @@ verifies('protocol:error:invalid-params', async ({ transport }: TestArgs) => {
await expect(call).rejects.toBeInstanceOf(ProtocolError);
// The malformed request did reach the wire (failure is server-side, not client-side validation).
// toMatchObject: on a 2026-07-28 connection the client auto-attaches the per-request `_meta`
// envelope, which is additive and not part of the assertion's intent.
const sent = outbound.filter(m => isRequest(m)).find(m => m.method === 'tools/call');
expect(sent?.params).toEqual({ arguments: {} });
expect(sent?.params).toMatchObject({ arguments: {} });
expect((sent?.params as { name?: unknown }).name).toBeUndefined();
expect(ProtocolErrorCode.InvalidParams).toBe(-32_602);
await expect(call).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams });
@ -1535,16 +1595,40 @@ class LoopbackTransport implements Transport {
this.events.push('method' in message ? `send:${message.method}` : 'send:response');
if (!isRequest(message)) return;
this.clientRequests.push(message);
if (message.method === 'initialize') {
this.respond(message.id, {
protocolVersion: this.serverProtocolVersion,
capabilities: { tools: {} },
serverInfo: { name: 'loopback-server', version: '3.1.4' }
});
} else if (message.method === 'tools/list') {
this.respond(message.id, {
tools: [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]
});
const modern = this.serverProtocolVersion >= '2026-07-28';
switch (message.method) {
case 'initialize': {
this.respond(message.id, {
protocolVersion: this.serverProtocolVersion,
capabilities: { tools: {} },
serverInfo: { name: 'loopback-server', version: '3.1.4' }
});
break;
}
case 'server/discover': {
// The 2026-era handshake: advertise the canned identity instead of
// answering an initialize exchange.
this.respond(message.id, {
supportedVersions: [this.serverProtocolVersion],
capabilities: { tools: {} },
serverInfo: { name: 'loopback-server', version: '3.1.4' }
});
break;
}
case 'tools/list': {
const tools = [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }];
this.respond(
message.id,
modern
? // The 2026 wire shape carries the result discriminator and the cacheable-result fields.
({ resultType: 'complete', ttlMs: 0, cacheScope: 'public', tools } as unknown as Result)
: { tools }
);
break;
}
default: {
break;
}
}
}
@ -1560,29 +1644,38 @@ class LoopbackTransport implements Transport {
verifies('transport:custom:client-connect', async ({ protocolVersion }: TestArgs) => {
// The body supplies its own consumer-implemented Transport, so the matrix transport arg is unused by design.
// On 2025-era cells the handshake is the plain initialize exchange; on 2026-era cells it is the
// server/discover negotiation (a 2026 revision is never negotiated via initialize), which the client opts
// into by pinning the cell's revision.
const modern = protocolVersion >= '2026-07-28';
const customTransport = new LoopbackTransport(protocolVersion);
const client = newClient();
const client = modern
? new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: protocolVersion } } })
: newClient();
const clientOnclose = vi.fn();
client.onclose = clientOnclose;
const handshake = modern ? ['send:server/discover'] : ['send:initialize', 'send:notifications/initialized'];
const handshakeRequests = modern ? ['server/discover'] : ['initialize'];
try {
await client.connect(customTransport);
// Protocol installed its callbacks on the consumer object before invoking start().
// Connect installed callbacks on the consumer object before invoking start().
expect(customTransport.callbacksPresentAtStart).toEqual({ onmessage: true, onclose: true, onerror: true });
// The full handshake ran over the consumer transport, and its canned identity is what the client now reports.
expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized']);
expect(customTransport.events).toEqual(['start', ...handshake]);
expect(client.getServerCapabilities()).toEqual({ tools: {} });
expect(client.getServerVersion()).toEqual({ name: 'loopback-server', version: '3.1.4' });
expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion);
// A post-handshake request round-trips through the consumer transport's send().
const listed = await client.listTools();
expect(listed.tools).toEqual([{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]);
expect(customTransport.clientRequests.map(m => m.method)).toEqual(['initialize', 'tools/list']);
expect(customTransport.clientRequests.map(m => m.method)).toEqual([...handshakeRequests, 'tools/list']);
await client.close();
// close() reached the consumer transport, and its onclose callback fed back into the client's close handling.
expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized', 'send:tools/list', 'close']);
expect(customTransport.events).toEqual(['start', ...handshake, 'send:tools/list', 'close']);
expect(clientOnclose).toHaveBeenCalledTimes(1);
expect(client.transport).toBeUndefined();
} finally {

View file

@ -0,0 +1,181 @@
/**
* Raw-first result discrimination through the full client path — ERA-SCOPED
* (Q1 increment 2: V-1 lives in the era codec's decodeResult, and the
* postures are ruled per era by Q1-SD3).
*
* A raw relay server (no SDK Server involved) answers tools/call with hand
* built bodies. The negotiated protocol version selects the wire era; the
* modern arms negotiate it through the real path (versionNegotiation +
* server/discover — a 2026 revision is never negotiated via initialize):
*
* - Negotiated 2026-07-28: `resultType` is the REQUIRED discriminator. An
* `input_required` body surfaces the discriminated kind as a typed local
* error (the multi-round-trip driver consumes it when it lands); an
* ABSENT `resultType` is a spec violation surfaced as a typed error
* naming it.
* - Negotiated legacy (2025 era): `resultType` is FOREIGN vocabulary —
* strip-on-lift (Q1-SD3 ii; a deliberate, ledgered change from the
* pre-split era-blind rejection — changeset: codec-split-wire-break). The
* stripped body then fails the (default-free) result schema loudly
* because it has no content.
*
* Either way the V-1 invariant holds: never an empty-content success.
*/
import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import type { JSONRPCRequest } from '@modelcontextprotocol/server';
import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
const INPUT_REQUIRED_BODY = {
resultType: 'input_required',
inputRequests: {
'elicit-1': {
method: 'elicitation/create',
params: { mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', properties: {} } }
}
},
requestState: 'opaque-state'
};
/** A complete-looking body that omits the (2026-required) resultType. */
const ABSENT_RESULT_TYPE_BODY = { content: [{ type: 'text', text: 'looks complete' }] };
function initializeResult(requestedVersion: string) {
return {
protocolVersion: requestedVersion,
capabilities: { tools: {} },
serverInfo: { name: 'raw-input-required-server', version: '0' }
};
}
function makeResponder(toolCallBody: unknown) {
return function respondTo(request: JSONRPCRequest): unknown {
if (request.method === 'initialize') {
const requested = (request.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
return initializeResult(requested);
}
if (request.method === 'server/discover') {
// The modern handshake: the relay advertises the draft revision so a
// negotiating client selects it (no initialize on that path).
return {
supportedVersions: ['2026-07-28'],
capabilities: { tools: {} },
serverInfo: { name: 'raw-input-required-server', version: '0' }
};
}
if (request.method === 'tools/call') return toolCallBody;
return {};
};
}
async function connectInMemory(client: Client, toolCallBody: unknown): Promise<void> {
const respondTo = makeResponder(toolCallBody);
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = message => {
const request = message as JSONRPCRequest;
if (request.id === undefined) return; // notifications need no answer
void serverTx.send({ jsonrpc: '2.0', id: request.id, result: respondTo(request) } as Parameters<typeof serverTx.send>[0]);
};
await serverTx.start();
await client.connect(clientTx);
}
async function connectStreamableHttp(client: Client, toolCallBody: unknown): Promise<void> {
const respondTo = makeResponder(toolCallBody);
// A hand HTTP handler (no SDK server): JSON responses, 202 for notifications.
const fetchHandler = async (input: URL | string, init?: RequestInit): Promise<Response> => {
const request = new Request(input, init);
if (request.method !== 'POST') return new Response(null, { status: 405 });
const body = (await request.json()) as JSONRPCRequest | JSONRPCRequest[];
const message = Array.isArray(body) ? body[0] : body;
if (message?.id === undefined) return new Response(null, { status: 202 });
return Response.json({ jsonrpc: '2.0', id: message.id, result: respondTo(message) });
};
await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchHandler }));
}
async function callToolOutcome(client: Client): Promise<{ resolved: unknown } | { rejected: unknown }> {
return client.callTool({ name: 'anything', arguments: {} }).then(
result => ({ resolved: result as unknown }),
error => ({ rejected: error as unknown })
);
}
verifies('typescript:client:raw-result-type-first', async ({ transport }: TestArgs) => {
// ---- Legacy negotiation (the relay echoes the client's default offer,
// so this connection negotiates a legacy version → 2025 era). ----
{
const client = new Client({ name: 'raw-result-type-client', version: '0' });
await (transport === 'inMemory'
? connectInMemory(client, INPUT_REQUIRED_BODY)
: connectStreamableHttp(client, INPUT_REQUIRED_BODY));
try {
const outcome = await callToolOutcome(client);
// Strip-on-lift (Q1-SD3 ii, ledgered): the foreign resultType is
// dropped; the body has no content, so validation fails LOUDLY.
// Never an empty-content success.
expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false);
const rejection = (outcome as { rejected: unknown }).rejected;
expect(rejection).toBeInstanceOf(SdkError);
expect((rejection as SdkError).code).toBe(SdkErrorCode.InvalidResult);
} finally {
await client.close();
}
}
// ---- Modern negotiation: the client pins the draft revision, the relay
// advertises it via server/discover → 2026 era → V-1 discrimination in
// the codec. Auto-fulfilment is disabled here so this requirement keeps
// proving the discrimination surface itself (the typed local error); the
// multi-round-trip driver has its own requirements (typescript:mrtr:*). ----
{
const client = new Client(
{ name: 'raw-result-type-client', version: '0' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } }, inputRequired: { autoFulfill: false } }
);
await (transport === 'inMemory'
? connectInMemory(client, INPUT_REQUIRED_BODY)
: connectStreamableHttp(client, INPUT_REQUIRED_BODY));
try {
const outcome = await callToolOutcome(client);
expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false);
const rejection = (outcome as { rejected: unknown }).rejected;
expect(rejection).toBeInstanceOf(SdkError);
const typed = rejection as SdkError;
expect(typed.code).toBe(SdkErrorCode.UnsupportedResultType);
expect(typed.data).toMatchObject({ resultType: 'input_required', method: 'tools/call' });
} finally {
await client.close();
}
}
// ---- Modern negotiation, absent resultType: the spec violation is
// surfaced as a typed error naming it (Q1-SD3 i — the absent⇒complete
// bridge applies only to earlier-revision servers). ----
{
const client = new Client(
{ name: 'raw-result-type-client', version: '0' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } }
);
await (transport === 'inMemory'
? connectInMemory(client, ABSENT_RESULT_TYPE_BODY)
: connectStreamableHttp(client, ABSENT_RESULT_TYPE_BODY));
try {
const outcome = await callToolOutcome(client);
expect('resolved' in outcome, `must not resolve: ${JSON.stringify(outcome)}`).toBe(false);
const rejection = (outcome as { rejected: unknown }).rejected;
expect(rejection).toBeInstanceOf(SdkError);
const typed = rejection as SdkError;
expect(typed.code).toBe(SdkErrorCode.InvalidResult);
expect(String(typed.message)).toContain('missing required resultType');
} finally {
await client.close();
}
}
});

View file

@ -78,21 +78,11 @@ verifies(
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const first = await client.listResources();
expect(first.resources.length).toBeLessThan(TOTAL);
expect(first.nextCursor).toBeDefined();
const seen = new Set(first.resources.map(r => r.uri));
let result = first;
let pages = 1;
while (result.nextCursor !== undefined) {
result = await client.listResources({ cursor: result.nextCursor });
for (const r of result.resources) seen.add(r.uri);
pages++;
expect(pages).toBeLessThan(50);
}
expect(seen.size).toBe(TOTAL);
expect(pages).toBeGreaterThan(1);
// No-arg listResources() auto-aggregates every page.
const all = await client.listResources();
expect(all.resources.length).toBe(TOTAL);
expect(all.nextCursor).toBeUndefined();
expect(new Set(all.resources.map(r => r.uri)).size).toBe(TOTAL);
},
{ title: 'mcpserver' }
);
@ -122,30 +112,20 @@ verifies(
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const seen = new Set<string>();
const cursorsSent: string[] = [];
let pages = 0;
let result = await client.listResources();
expect(result.nextCursor).toBeDefined();
for (;;) {
for (const r of result.resources) {
expect(seen.has(r.uri)).toBe(false);
seen.add(r.uri);
}
pages++;
if (result.nextCursor === undefined) break;
cursorsSent.push(result.nextCursor);
result = await client.listResources({ cursor: result.nextCursor });
expect(pages).toBeLessThan(50);
}
// No-arg listResources() auto-aggregates every page; the server
// receives the cursor walk verbatim (protocol-level pagination is
// what is verified here).
const result = await client.listResources();
expect(result.nextCursor).toBeUndefined();
expect(pages).toBe(3);
const seen = new Set(result.resources.map(r => r.uri));
expect(seen.size).toBe(TOTAL);
for (const name of all) expect(seen.has(name)).toBe(true);
expect(cursorsReceived).toEqual([undefined, '10', '20']);
expect(cursorsSent).toEqual(['10', '20']);
// Explicit cursor → one raw page (per-page path).
const page = await client.listResources({ cursor: '10' });
expect(page.resources.length).toBe(PAGE);
expect(page.nextCursor).toBe('20');
},
{ title: 'raw server' }
);
@ -202,10 +182,32 @@ verifies('resources:read:unknown-uri', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, makeServer, client);
await expect(client.readResource({ uri: 'file:///no-such-resource' })).rejects.toMatchObject({
code: -32_002,
message: expect.stringMatching(/not found|unknown/i)
});
let received: ProtocolError | undefined;
try {
const result = await client.readResource({ uri: 'file:///no-such-resource' });
// MUST-NOT rider: never an empty contents array for a non-existent resource.
expect(result.contents).not.toEqual([]);
} catch (error) {
received = error as ProtocolError;
}
expect(received).toBeDefined();
// The wire code is −32602 on every protocol revision (the encode seam owns
// the −32002 → −32602 mapping), with `data.uri` echoing the requested URI.
expect(received!.code).toBe(-32_602);
expect(received!.message).toMatch(/not found/i);
expect(received!.data).toEqual({ uri: 'file:///no-such-resource' });
// The cross-bundle data-parse recognizer reconstructs the typed error
// from code + structurally valid data (no `instanceof` across bundles).
// It accepts BOTH −32602 and the legacy −32002; the duck shape is `data.uri`.
const recognised = ProtocolError.fromError(received!.code, received!.message, received!.data);
expect((recognised as { uri?: string }).uri).toBe('file:///no-such-resource');
const legacy = ProtocolError.fromError(-32_002, 'Resource not found', { uri: 'file:///x' });
expect((legacy as { uri?: string }).uri).toBe('file:///x');
// ProtocolErrorCode.ResourceNotFound (−32002) stays importable as legacy
// receive-tolerated vocabulary.
expect(ProtocolErrorCode.ResourceNotFound).toBe(-32_002);
});
verifies('resources:read:template-vars', async ({ transport }: TestArgs) => {
@ -498,21 +500,11 @@ verifies(
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const first = await client.listResourceTemplates();
expect(first.resourceTemplates.length).toBeLessThan(TOTAL);
expect(first.nextCursor).toBeDefined();
const seen = new Set(first.resourceTemplates.map(t => t.uriTemplate));
let result = first;
let pages = 1;
while (result.nextCursor !== undefined) {
result = await client.listResourceTemplates({ cursor: result.nextCursor });
for (const t of result.resourceTemplates) seen.add(t.uriTemplate);
pages++;
expect(pages).toBeLessThan(50);
}
expect(seen.size).toBe(TOTAL);
expect(pages).toBeGreaterThan(1);
// No-arg listResourceTemplates() auto-aggregates every page.
const all = await client.listResourceTemplates();
expect(all.resourceTemplates.length).toBe(TOTAL);
expect(all.nextCursor).toBeUndefined();
expect(new Set(all.resourceTemplates.map(t => t.uriTemplate)).size).toBe(TOTAL);
},
{ title: 'mcpserver' }
);
@ -539,25 +531,17 @@ verifies(
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const seen = new Set<string>();
let pages = 0;
let result = await client.listResourceTemplates();
expect(result.nextCursor).toBeDefined();
for (;;) {
for (const t of result.resourceTemplates) {
expect(seen.has(t.uriTemplate)).toBe(false);
seen.add(t.uriTemplate);
}
pages++;
if (result.nextCursor === undefined) break;
result = await client.listResourceTemplates({ cursor: result.nextCursor });
expect(pages).toBeLessThan(50);
}
// No-arg listResourceTemplates() auto-aggregates every page.
const result = await client.listResourceTemplates();
expect(result.nextCursor).toBeUndefined();
expect(pages).toBe(3);
const seen = new Set(result.resourceTemplates.map(t => t.uriTemplate));
expect(seen.size).toBe(TOTAL);
for (const name of all) expect(seen.has(name)).toBe(true);
// Explicit cursor → one raw page (per-page path).
const page = await client.listResourceTemplates({ cursor: '10' });
expect(page.resourceTemplates.length).toBe(PAGE);
expect(page.nextCursor).toBe('20');
},
{ title: 'raw server' }
);

View file

@ -202,7 +202,7 @@ verifies('sampling:error:user-rejected', async ({ transport }: TestArgs) => {
const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } });
expect(r.structuredContent).toMatchObject({ ok: false, code: -1 });
expect(r.structuredContent?.message).toMatch(/User rejected sampling request/);
expect((r.structuredContent as { message?: unknown }).message).toMatch(/User rejected sampling request/);
});
verifies('sampling:message:content-cardinality', async ({ transport }: TestArgs) => {
@ -340,7 +340,7 @@ verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs
});
expect(r.structuredContent).toMatchObject({ ok: false, code: ProtocolErrorCode.InvalidParams });
expect(r.structuredContent?.message).toMatch(/tool.?result/i);
expect((r.structuredContent as { message?: unknown }).message).toMatch(/tool.?result/i);
});
verifies('sampling:tool-use:result-balance', async ({ transport }: TestArgs) => {
@ -425,7 +425,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test
});
expect(withTools.structuredContent).toMatchObject({ ok: false });
expect(withTools.structuredContent?.message).toMatch(/sampling.*tools/i);
expect((withTools.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i);
expect(received).toHaveLength(0);
const withChoice = await client.callTool({
@ -434,7 +434,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test
});
expect(withChoice.structuredContent).toMatchObject({ ok: false });
expect(withChoice.structuredContent?.message).toMatch(/sampling.*tools/i);
expect((withChoice.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i);
expect(received).toHaveLength(0);
const empty = await client.callTool({
@ -443,7 +443,7 @@ verifies('sampling:tools:server-gated-by-capability', async ({ transport }: Test
});
expect(empty.structuredContent).toMatchObject({ ok: false });
expect(empty.structuredContent?.message).toMatch(/sampling.*tools/i);
expect((empty.structuredContent as { message?: unknown }).message).toMatch(/sampling.*tools/i);
expect(received).toHaveLength(0);
});

View file

@ -0,0 +1,91 @@
/**
* SEP-2243 request-metadata headers (protocol revision 2026-07-28).
*
* End-to-end cells for the SEP-2243 header families over the dual-era HTTP
* entry (`createMcpHandler`), exercised on the wire() `entryModern` arm so the
* raw HTTP request headers are observable on the arm-recorded `wired.httpLog`.
*/
import { Client } from '@modelcontextprotocol/client';
import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextprotocol/core-internal';
import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { modernEnvelopeMeta, wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
/**
* One tool with a single `x-mcp-header`-declared string parameter. Declared as
* a non-literal const so the JSON-Schema vendor extension key passes excess
* property checking on `fromJsonSchema`'s `JSONSchema.Interface` parameter.
*/
const LOCATE_INPUT_SCHEMA = {
type: 'object',
properties: { region: { type: 'string', 'x-mcp-header': 'Region' } },
required: ['region']
};
verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => {
// The server is built by createMcpHandler per request, so its pre-dispatch
// Mcp-Param-* validation runs against this schema.
const makeServer = () => {
const server = new McpServer({ name: 'e2e-sep2243', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool('locate', { inputSchema: fromJsonSchema<{ region: string }>(LOCATE_INPUT_SCHEMA) }, ({ region }) => ({
content: [{ type: 'text', text: `region=${region}` }]
}));
return server;
};
const client = new Client({ name: 'sep2243-client', version: '1.0.0' });
await using wired = await wire(transport, makeServer, client);
// listTools() auto-aggregates and writes the response cache; callTool
// reads it directly and emits the header on its first attempt (the
// spec's 5-step client algorithm).
await client.listTools();
const result = await client.callTool({ name: 'locate', arguments: { region: 'us-west1' } });
// The tools/call HTTP request carries the Mcp-Param-Region header,
// encoded per the SEP-2243 value-encoding rules (a safe ASCII token
// passes through unchanged).
const callExchange = (wired.httpLog ?? []).find(exchange => exchange.requestBody?.includes('"tools/call"'));
expect(callExchange).toBeDefined();
const headerValue = callExchange!.requestHeaders.get(`${MCP_PARAM_HEADER_PREFIX}Region`);
expect(headerValue).toBe(encodeMcpParamValue('us-west1'));
expect(headerValue).toBe('us-west1');
// The call succeeded against the validating server (header agreed with
// the body argument, so no -32020 HeaderMismatch on the wire).
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]);
});
verifies('sep-2243:std-header:mismatch-rejected', async ({ transport }: TestArgs) => {
const makeServer = () => new McpServer({ name: 'e2e-sep2243-std', version: '1.0.0' }, { capabilities: { tools: {} } });
const client = new Client({ name: 'sep2243-std-client', version: '1.0.0' });
await using wired = await wire(transport, makeServer, client);
// Raw POST through the harness-hosted entry: the body is a valid
// envelope-carrying tools/call, but the Mcp-Method header names
// tools/list. The era-classification rung answers the disagreement
// before any factory instance is constructed.
const response = await wired.fetch!(wired.url!, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-method': 'tools/list'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'locate', arguments: {}, _meta: modernEnvelopeMeta({ name: 'sep2243-std-client', version: '1.0.0' }) }
})
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: { code: number; message: string } };
// -32020 is the SEP-2243 HeaderMismatch code (post-spec#2907 renumber).
expect(body.error.code).toBe(-32_020);
expect(body.error.message).toMatch(/Mcp-Method/);
});

View file

@ -0,0 +1,80 @@
/**
* Self-contained test bodies for dual-era stdio serving.
*
* Like the other transport:stdio scenarios these do not use `wire()`: each
* body spawns the dual-era fixture server in
* `fixtures/dual-era-stdio-server.ts` (the connection-pinned `serveStdio`
* entry over an ordinary McpServer factory) as a real child process via
* {@link StdioClientTransport}. The matrix `transport` arg is ignored (the
* requirement lists `transports: ['stdio']`); the spec-version axis selects
* which client opens the connection — a plain 2025 client over `initialize`,
* or the auto-negotiating client reaching 2026-07-28 over `server/discover` —
* and the entry pins that connection's instance to the era the client opened
* with.
*/
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { expect } from 'vitest';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
/** Absolute path to the runnable dual-era fixture server (executed with tsx). */
const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/dual-era-stdio-server.ts', import.meta.url));
/** E2E package root — spawn cwd so node/tsx resolve the local toolchain and workspace packages. */
const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url));
verifies('typescript:transport:stdio:dual-era-serving', async ({ protocolVersion }: TestArgs) => {
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--import', 'tsx', FIXTURE_PATH],
cwd: E2E_ROOT
});
if (protocolVersion === '2025-11-25') {
// Legacy leg: a plain 2025 client opens with initialize and the entry
// pins the connection to a 2025-era instance, served exactly as a
// hand-wired stdio server serves it today.
const client = new Client({ name: 'plain-2025-client', version: '0' });
try {
await client.connect(transport);
expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion);
const result = await client.callTool({ name: 'echo', arguments: { text: 'legacy leg' } });
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'legacy leg' }]);
} finally {
await client.close();
await transport.close();
}
return;
}
// Modern leg: the auto-negotiating client reaches 2026-07-28 via
// server/discover on the pipe (no initialize is ever written), the entry
// pins the connection to a 2026-era instance, and tools/call round-trips
// with the per-request envelope.
const sentMethods: string[] = [];
const originalSend = transport.send.bind(transport);
transport.send = async message => {
if ('method' in message) sentMethods.push(message.method);
return originalSend(message);
};
const client = new Client({ name: 'auto-client', version: '0' }, { versionNegotiation: { mode: 'auto' } });
try {
await client.connect(transport);
expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion);
expect(sentMethods).not.toContain('initialize');
expect(sentMethods[0]).toBe('server/discover');
const result = await client.callTool({ name: 'echo', arguments: { text: 'modern leg' } });
expect(result.content).toEqual([{ type: 'text', text: 'modern leg' }]);
} finally {
await client.close();
await transport.close();
}
});

View file

@ -0,0 +1,292 @@
/**
* `subscriptions/listen` (SEP-1865, protocol revision 2026-07-28) through the
* public surface: ack-first, subscription-id stamping, per-stream filtering,
* the listChanged auto-open bridge, and the F-12 legacy steer.
*
* The 2026-era cells host `createMcpHandler` themselves (the test publishes
* via `handler.notify.*`); the legacy cell runs on the standard arms.
*/
import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/server';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { modernEnvelopeMeta, wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
function makeServer() {
const server = new McpServer({ name: 'subs-e2e', version: '1' });
server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] }));
return server;
}
/**
* A modern in-process host with a tool, a prompt, and a resource registered so
* the entry advertises listChanged for all three kinds (the listen ack honors a
* filter only for kinds the server advertises).
*/
async function hostListenAllKinds() {
const factory = () => {
const server = new McpServer({ name: 'subs-e2e', version: '1' });
server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] }));
server.registerPrompt('hello', { description: 'p' }, async () => ({ messages: [] }));
server.registerResource('r', 'file:///r', {}, async uri => ({ contents: [{ uri: uri.href, text: 'r' }] }));
return server;
};
const handler = createMcpHandler(factory, { legacy: 'reject', keepAliveMs: 0 });
const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init));
const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch }));
expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28');
return {
client,
handler,
factory,
fetch,
[Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {})
};
}
async function hostListen() {
const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init));
const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(new StreamableHTTPClientTransport(url, { fetch }));
expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28');
return {
client,
handler,
fetch,
url,
[Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {})
};
}
verifies('subscriptions:listen:ack-first-stamped', async () => {
const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 });
const response = await handler.fetch(
new Request('http://in-process/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'sub-1',
method: 'subscriptions/listen',
params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } }
})
})
);
expect(response.headers.get('Content-Type')).toBe('text/event-stream');
const reader = response.body!.getReader();
const { value } = await reader.read();
const frame = new TextDecoder().decode(value);
const ack = JSON.parse(frame.slice(frame.indexOf('data: ') + 6, frame.indexOf('\n\n'))) as {
method: string;
params: { _meta: Record<string, unknown>; notifications: unknown };
};
expect(ack.method).toBe('notifications/subscriptions/acknowledged');
expect(ack.params._meta[SUBSCRIPTION_ID_META_KEY]).toBe('sub-1');
expect(ack.params.notifications).toEqual({ toolsListChanged: true });
await reader.cancel();
await handler.close();
});
verifies('subscriptions:listen:graceful-close', async () => {
// Hosted directly so the test owns handler.close(); `await using` of
// hostListen() would close on dispose and obscure the assertion.
const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 });
const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init));
const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch }));
const sub = await client.listen({ toolsListChanged: true });
// Server-side graceful close: the entry's listen router emits the empty
// SubscriptionsListenResult as the final SSE frame, then closes the
// stream. The client surfaces this as `closed: 'graceful'` (distinct from
// `'remote'`, which is the transport-drop / no-result path).
await handler.close();
await expect(sub.closed).resolves.toBe('graceful');
await client.close();
});
verifies('subscriptions:listen:per-stream-filter', async () => {
await using h = await hostListen();
const seen: string[] = [];
h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools'));
h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts'));
const sub = await h.client.listen({ toolsListChanged: true });
h.handler.notify.promptsChanged();
h.handler.notify.toolsChanged();
await new Promise(r => setTimeout(r, 30));
// The un-requested type was provably never delivered.
expect(seen).toEqual(['tools']);
await sub.close();
});
verifies('typescript:subscriptions:listChanged-auto-open-modern', async () => {
const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 });
const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init));
let count = 0;
let done!: () => void;
const finished = new Promise<void>(r => {
done = r;
});
const client = new Client(
{ name: 'subs-e2e-client', version: '1' },
{
versionNegotiation: { mode: 'auto' },
listChanged: { tools: { autoRefresh: false, onChanged: () => (++count >= 1 ? done() : undefined) } }
}
);
await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch }));
expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true });
handler.notify.toolsChanged();
await finished;
expect(count).toBe(1);
await client.autoOpenedSubscription!.close();
await client.close();
await handler.close();
});
verifies('typescript:subscriptions:listen:legacy-era-steer', async ({ transport }: TestArgs) => {
const client = new Client({ name: 'c', version: '0' });
await using _ = await wire(transport, makeServer, client);
const error = await client.listen({ toolsListChanged: true }).catch(error_ => error_ as SdkError);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion);
expect((error as SdkError).message).toContain('resources/subscribe');
});
verifies('subscriptions:listen:honored-filter-narrows-to-advertised', async () => {
// makeServer registers a tool but no prompts/resources: a listen requesting
// toolsListChanged + promptsListChanged + resourcesListChanged must come
// back honored as toolsListChanged only — the ack reflects only what the
// server advertises.
await using h = await hostListen();
const sub = await h.client.listen({ toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true });
expect(sub.honoredFilter).toEqual({ toolsListChanged: true });
// And nothing the server doesn't advertise reaches the stream: the entry
// delivers via the same narrowed filter it acknowledged.
const seen: string[] = [];
h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts'));
h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools'));
h.handler.notify.promptsChanged();
h.handler.notify.toolsChanged();
await new Promise(r => setTimeout(r, 30));
expect(seen).toEqual(['tools']);
await sub.close();
});
// 2026-era siblings of the captured-instance list_changed publish rows: the
// publication path is handler.notify.* and delivery rides subscriptions/listen.
verifies('tools:listen:list-changed', async () => {
await using h = await hostListenAllKinds();
const seen: string[] = [];
h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools'));
const sub = await h.client.listen({ toolsListChanged: true });
expect(sub.honoredFilter).toEqual({ toolsListChanged: true });
h.handler.notify.toolsChanged();
await new Promise(r => setTimeout(r, 30));
expect(seen).toEqual(['tools']);
await sub.close();
});
verifies('resources:listen:list-changed', async () => {
await using h = await hostListenAllKinds();
const seen: string[] = [];
h.client.setNotificationHandler('notifications/resources/list_changed', () => void seen.push('resources'));
const sub = await h.client.listen({ resourcesListChanged: true });
expect(sub.honoredFilter).toEqual({ resourcesListChanged: true });
h.handler.notify.resourcesChanged();
await new Promise(r => setTimeout(r, 30));
expect(seen).toEqual(['resources']);
await sub.close();
});
verifies('prompts:listen:list-changed', async () => {
await using h = await hostListenAllKinds();
const seen: string[] = [];
h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts'));
const sub = await h.client.listen({ promptsListChanged: true });
expect(sub.honoredFilter).toEqual({ promptsListChanged: true });
h.handler.notify.promptsChanged();
await new Promise(r => setTimeout(r, 30));
expect(seen).toEqual(['prompts']);
await sub.close();
});
verifies('client:listen:auto-refresh', async () => {
const factory = () => {
const server = new McpServer({ name: 'subs-e2e', version: '1' });
server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] }));
return server;
};
const handler = createMcpHandler(factory, { legacy: 'reject', keepAliveMs: 0 });
const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init));
let done!: () => void;
const finished = new Promise<void>(r => {
done = r;
});
const refreshed: unknown[] = [];
const client = new Client(
{ name: 'subs-e2e-client', version: '1' },
{
versionNegotiation: { mode: 'auto' },
listChanged: {
tools: {
onChanged: (error, tools) => {
expect(error).toBeNull();
refreshed.push(tools);
done();
}
}
}
}
);
await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch }));
expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true });
handler.notify.toolsChanged();
await finished;
// The auto-refresh re-fetched tools/list and delivered the fresh result.
expect(refreshed).toHaveLength(1);
expect(refreshed[0]).toEqual([expect.objectContaining({ name: 'greet' })]);
await client.autoOpenedSubscription!.close();
await client.close();
await handler.close();
});
verifies('subscriptions:listen:capacity-guard', async () => {
const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0, maxSubscriptions: 1 });
const post = (id: number) =>
handler.fetch(
new Request('http://in-process/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({
jsonrpc: '2.0',
id,
method: 'subscriptions/listen',
params: { _meta: modernEnvelopeMeta(), notifications: {} }
})
})
);
const first = await post(1);
expect(first.headers.get('Content-Type')).toBe('text/event-stream');
const second = await post(2);
expect(second.headers.get('Content-Type')).toContain('application/json');
const body = (await second.json()) as { error: { code: number; message: string } };
expect(body.error.code).toBe(-32_603);
expect(body.error.message).toBe('Subscription limit reached');
await first.body!.cancel();
await handler.close();
});

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