Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
24 changed files with 69131 additions and 9001 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
337
test/e2e/scenarios/hosting-entry-auth.test.ts
Normal file
337
test/e2e/scenarios/hosting-entry-auth.test.ts
Normal 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(() => {});
|
||||
});
|
||||
155
test/e2e/scenarios/hosting-entry-http.test.ts
Normal file
155
test/e2e/scenarios/hosting-entry-http.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
197
test/e2e/scenarios/hosting-entry-session.test.ts
Normal file
197
test/e2e/scenarios/hosting-entry-session.test.ts
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
160
test/e2e/scenarios/hosting-entry-stamping.test.ts
Normal file
160
test/e2e/scenarios/hosting-entry-stamping.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
154
test/e2e/scenarios/hosting-entry-streaming.test.ts
Normal file
154
test/e2e/scenarios/hosting-entry-streaming.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
178
test/e2e/scenarios/hosting-entry.test.ts
Normal file
178
test/e2e/scenarios/hosting-entry.test.ts
Normal 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('');
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
596
test/e2e/scenarios/jsonschema.test.ts
Normal file
596
test/e2e/scenarios/jsonschema.test.ts
Normal 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']
|
||||
});
|
||||
});
|
||||
510
test/e2e/scenarios/mrtr.test.ts
Normal file
510
test/e2e/scenarios/mrtr.test.ts
Normal 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] });
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
181
test/e2e/scenarios/raw-result-type.test.ts
Normal file
181
test/e2e/scenarios/raw-result-type.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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' }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
91
test/e2e/scenarios/sep2243.test.ts
Normal file
91
test/e2e/scenarios/sep2243.test.ts
Normal 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/);
|
||||
});
|
||||
80
test/e2e/scenarios/stdio-dual-era.test.ts
Normal file
80
test/e2e/scenarios/stdio-dual-era.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
292
test/e2e/scenarios/subscriptions.test.ts
Normal file
292
test/e2e/scenarios/subscriptions.test.ts
Normal 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
Loading…
Reference in a new issue