Implement MCP 2026-07-28 (#2286)

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

View file

@ -2,4 +2,32 @@
import baseConfig from '@modelcontextprotocol/eslint-config';
export default baseConfig;
export default [
...baseConfig,
{
files: ['**/*.{ts,tsx,js,jsx,mts,cts}'],
rules: {
// Conformance fixtures MUST use only what a consumer would `npm install` and import:
// public package entry points. Anything reaching into core-internal or package internals is banned.
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@modelcontextprotocol/core-internal', '@modelcontextprotocol/core-internal/*'],
message: 'Conformance fixtures must import from @modelcontextprotocol/{server,client}, not core-internal.'
},
{
group: ['@modelcontextprotocol/*/src/*'],
message: 'Conformance fixtures must import only public package entry points.'
},
{
group: ['@modelcontextprotocol/*/dist/*'],
message: 'Conformance fixtures must import only public package entry points.'
}
]
}
]
}
}
];

View file

@ -0,0 +1,34 @@
# Expected failures for the carried-forward x 2026-07-28 legs
# (`test:conformance:client:2026` and `test:conformance:server:2026`, both
# `--suite all --spec-version 2026-07-28`).
#
# This baseline is separate from expected-failures.yaml because entries are
# keyed by scenario name only: a scenario that passes at its default version
# in the 2025 legs but fails when forced to 2026-07-28 (or vice versa) cannot
# be expressed in a shared file (the passing leg would flag the entry as
# stale). Like expected-failures.yaml, this single file covers both
# directions: the client 2026 leg reads the `client:` section and the server
# 2026 leg reads the `server:` section. Both burn down independently of the
# 2025 legs.
#
# Baseline established against the published @modelcontextprotocol/conformance
# release pinned in package.json. Newer conformance releases are adopted by
# deliberately bumping the pin and reconciling this file in the same change.
#
# NOTE: the SDK's modern-path rejection codes are aligned with what this
# referee asserts — both sides have adopted the spec#2907 / conformance#353
# renumber (-32020 / -32021 / -32022) on emission and recognition.
#
# Entries are grouped by what unblocks them. As each gap closes the
# corresponding scenarios start passing and MUST be removed from this list
# (the runner fails on stale entries), so the baseline burns down per
# milestone.
client: []
# --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) ---
# (empty: SEP-2468/2352/2350/837 burned by the auth bundle; SEP-2106 burned earlier)
server: []
# --- Carried-forward scenarios (also run by the 2025 legs) ---
# (empty: json-schema-2020-12 burned by SEP-2106 fixture; sep-2164-resource-not-found
# burned by the spec#2907 error-code renumber + alpha.5 referee.)

View file

@ -2,92 +2,40 @@
# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries.
#
# Baseline established against the published @modelcontextprotocol/conformance
# release pinned in package.json (0.2.0-alpha.3). Newer conformance releases
# release pinned in package.json (0.2.0-alpha.7). Newer conformance releases
# are adopted by deliberately bumping the package.json pin and reconciling
# this file in the same change. 0.2.0-alpha.3 fixes the draft wire version
# (2026-07-28). Several auth scenarios in this baseline (auth/iss-*,
# auth/authorization-server-migration, auth/enterprise-managed-authorization)
# are still not shipped in the published release — the runner reports them
# unknown/failed; their entries below cover them either way.
# this file in the same change.
#
# NOTE: the draft error-code assignments exercised by the SEP-2243 server
# scenarios (-32001 HeaderMismatch) and their neighbours (-32602, -32004) are
# still under discussion upstream (pending conformance #336). Those cells are
# treated as parameterized, not settled: the entries below record today's
# referee behavior and are re-derived when a #336-containing referee is pinned.
# NOTE: the SDK's modern-path rejection codes are aligned with what this
# referee asserts — both sides have adopted the spec#2907 / conformance#353
# renumber (-32020 HeaderMismatch / -32021 MissingRequiredClientCapability /
# -32022 UnsupportedProtocolVersion) on emission and recognition. A missing
# _meta envelope (or missing protocolVersion key) still answers -32602 on
# both sides.
#
# Entries are grouped by SEP. As each SEP/milestone is implemented in the SDK the
# corresponding scenarios start passing and MUST be removed from this list (the
# runner fails on stale entries), so the baseline burns down per milestone.
client:
client: []
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
# SEP-2575 (request metadata / _meta envelope): client does not populate the
# _meta envelope or the MCP-Protocol-Version header semantics yet.
- request-metadata
# SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet.
- sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
- http-custom-headers
- http-invalid-tool-headers
# SEP-2106 (JSON Schema $ref handling): client still dereferences network $refs.
- json-schema-ref-no-deref
# SEP-2468 (authorization response iss parameter): not implemented in the client.
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized
- auth/metadata-issuer-mismatch
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
# SEP-837 (application_type during DCR): the check only fires on draft-version
# runs; this draft scenario is the one place the client still hits it.
- auth/offline-access-not-supported
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-2350 (scope step-up): WARNING-only — client does not compute the union of
# previously requested and newly challenged scopes on re-authorization; the
# expected-failures evaluator counts WARNINGs as failures.
- auth/scope-step-up
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
# client support for the token-exchange + JWT bearer flow.
- auth/enterprise-managed-authorization
# (empty: SEP-2468/2352/2350/837/2207/990 burned by the auth bundle; the
# last referee-side gap — conformance#361 callback-iss — closed at alpha.6)
server:
# --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) ---
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
# _meta-derived capabilities, error-code mappings, or server/discover yet.
- server-stateless
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented;
# most scenarios currently fail early with "Session ID required" because the
# fixture only runs in stateful mode.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
- input-required-result-basic-list-roots
- input-required-result-request-state
- input-required-result-multiple-input-requests
- input-required-result-multi-round
- input-required-result-non-tool-request
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the
# stateful-mode "Session ID required" error.
- caching
# SEP-2243 (HTTP header standardization): -32001 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
# (Error-code cells parameterized pending conformance #336 — see header note.)
- http-header-validation
- http-custom-header-server-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2164: server returns -32002 without the requested URI in error.data.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore
# unrecognized inputResponses keys).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
# --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension ---
# Extension-tagged scenarios; selected only by `--suite all` (the alpha.7 referee
# has no server-side `--suite extensions`). The active/draft/2026 legs never select
# them, so they cannot flag these entries as stale. `tasks-status-notifications` is
# intentionally absent: the referee SKIPs it unconditionally (harness rewrite pending
# against the SEP-2575 subscriptions/listen channel), so a baseline entry would be
# flagged stale.
- tasks-lifecycle
- tasks-capability-negotiation
- tasks-wire-fields
- tasks-request-state-removal
- tasks-mrtr-input
- tasks-request-headers
- tasks-dispatch-and-envelope
- tasks-required-task-error
- tasks-mrtr-composition

View file

@ -27,15 +27,18 @@
"check": "npm run typecheck && npm run lint",
"test:conformance:client": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite core --expected-failures ./expected-failures.yaml",
"test:conformance:client:all": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --expected-failures ./expected-failures.yaml",
"test:conformance:client:2026": "conformance client --command 'node --import tsx ./src/everythingClient.ts' --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml",
"test:conformance:client:run": "node --import tsx ./src/everythingClient.ts",
"test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml",
"test:conformance:server:draft": "scripts/run-server-conformance.sh --suite draft --expected-failures ./expected-failures.yaml",
"test:conformance:server:extensions": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml",
"test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml",
"test:conformance:server:2026": "scripts/run-server-conformance.sh --suite all --spec-version 2026-07-28 --expected-failures ./expected-failures.2026-07-28.yaml",
"test:conformance:server:run": "node --import tsx ./src/everythingServer.ts",
"test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all"
},
"devDependencies": {
"@modelcontextprotocol/conformance": "0.2.0-alpha.3",
"@modelcontextprotocol/conformance": "0.2.0-alpha.7",
"@modelcontextprotocol/client": "workspace:^",
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/core-internal": "workspace:^",

View file

@ -51,17 +51,10 @@ const ADMIN_SCOPE = 'admin';
// Function to create a new MCP server instance (one per session)
function createMcpServer(): McpServer {
const mcpServer = new McpServer(
{
name: 'mcp-auth-test-server',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
const mcpServer = new McpServer({
name: 'mcp-auth-test-server',
version: '1.0.0'
});
// Simple echo tool for testing authenticated calls
mcpServer.registerTool(
@ -72,8 +65,7 @@ function createMcpServer(): McpServer {
message: z.string().optional().describe('The message to echo back')
})
},
async (args: { message?: string }) => {
const message = args.message || 'No message provided';
async ({ message = 'No message provided' }) => {
return {
content: [{ type: 'text', text: `Echo: ${message}` }]
};
@ -102,8 +94,7 @@ function createMcpServer(): McpServer {
action: z.string().optional().describe('The admin action to perform')
})
},
async (args: { action?: string }) => {
const action = args.action || 'default-admin-action';
async ({ action = 'default-admin-action' }) => {
return {
content: [{ type: 'text', text: `Admin action performed: ${action}` }]
};
@ -274,8 +265,8 @@ async function startServer() {
app.use(
cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'Authorization']
exposedHeaders: ['Mcp-Session-Id', 'WWW-Authenticate'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'Authorization', 'mcp-protocol-version']
})
);

View file

@ -64,6 +64,15 @@ const ClientConformanceContextSchema = z.discriminatedUnion('name', [
idp_id_token: z.string(),
idp_issuer: z.string(),
idp_token_endpoint: z.string()
}),
z.object({
name: z.literal('auth/enterprise-managed-authorization'),
client_id: z.string(),
client_secret: z.string(),
idp_client_id: z.string(),
idp_id_token: z.string(),
idp_issuer: z.string(),
idp_token_endpoint: z.string()
})
]);
@ -96,6 +105,25 @@ function registerScenarios(names: string[], handler: ScenarioHandler): void {
}
}
// ============================================================================
// 2026-07-28 (modern era) helpers
// ============================================================================
/**
* Spec versions whose wire lifecycle is the 2026-07-28 per-request envelope
* (no `initialize` handshake). The conformance runner passes the resolved
* spec version of the current scenario run via the
* MCP_CONFORMANCE_PROTOCOL_VERSION environment variable; when it names a
* modern version, version-spanning scenarios (e.g. tools_call) must speak the
* modern lifecycle instead of the 2025 stateful one.
*/
const MODERN_SPEC_VERSIONS = new Set(['2026-07-28']);
function isModernConformanceRun(): boolean {
const version = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION;
return version !== undefined && MODERN_SPEC_VERSIONS.has(version);
}
// ============================================================================
// Basic scenarios (initialize, tools_call)
// ============================================================================
@ -117,6 +145,10 @@ async function runBasicClient(serverUrl: string): Promise<void> {
// tools_call scenario needs to actually call a tool
async function runToolsCallClient(serverUrl: string): Promise<void> {
if (isModernConformanceRun()) {
return runToolsCallModernClient(serverUrl);
}
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
@ -128,8 +160,7 @@ async function runToolsCallClient(serverUrl: string): Promise<void> {
logger.debug('Successfully listed tools');
// Call the add_numbers tool
const addTool = tools.tools.find(t => t.name === 'add_numbers');
if (addTool) {
if (tools.tools.some(t => t.name === 'add_numbers')) {
const result = await client.callTool({
name: 'add_numbers',
arguments: { a: 5, b: 3 }
@ -141,15 +172,274 @@ async function runToolsCallClient(serverUrl: string): Promise<void> {
logger.debug('Connection closed successfully');
}
// tools_call under a 2026-07-28 run: negotiate the modern era via
// server/discover (versionNegotiation), then drive the same tool flow — the
// client attaches the per-request _meta envelope to every request itself.
async function runToolsCallModernClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion());
const tools = await client.listTools();
logger.debug('Successfully listed tools');
// Call the add_numbers tool
if (tools.tools.some(t => t.name === 'add_numbers')) {
const result = await client.callTool({
name: 'add_numbers',
arguments: { a: 5, b: 3 }
});
logger.debug('Tool call result:', JSON.stringify(result, null, 2));
}
await client.close();
logger.debug('Connection closed successfully');
}
// request-metadata scenario (SEP-2575): every request must carry the
// MCP-Protocol-Version header and the per-request _meta envelope, and the
// client must retry with a supported version when its first choice is
// rejected with -32022. The version-negotiation probe (server/discover plus
// the corrective continuation) is exactly that mechanism.
async function runRequestMetadataClient(serverUrl: string): Promise<void> {
const clientInfo = { name: 'test-client', version: '1.0.0' };
const client = new Client(clientInfo, {
capabilities: { roots: { listChanged: true }, sampling: {}, elicitation: {} },
versionNegotiation: { mode: 'auto' }
});
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion());
await client.close();
logger.debug('Connection closed successfully');
}
registerScenario('initialize', runBasicClient);
registerScenario('tools_call', runToolsCallClient);
registerScenario('request-metadata', runRequestMetadataClient);
// ============================================================================
// SEP-2243 standard-header client scenario (Mcp-Method / Mcp-Name)
// ============================================================================
// http-standard-headers: the referee mock answers initialize, tools/list,
// tools/call, resources/list, resources/read, prompts/list, prompts/get and
// asserts that each POST carried the correct Mcp-Method header (and Mcp-Name
// for the call/read/get verbs). The SDK emits both headers on the modern
// streamableHttp path, so the fixture just needs to drive each method once.
// The mock has no server/discover handler and its 2025-shaped initialize
// response doesn't satisfy the v2 client — same connect-time gap as the other
// SEP-2243 mocks — so connect via the withLocalDiscoverResponse shim. The
// initialize / notifications/initialized checks are intentionally left
// SKIPPED; the legacy initialize path's missing Mcp-Method is tracked as a
// baseline bug. The mock advertises its own surface (test_headers /
// file:///path/to/file%20name.txt / test_prompt) — the fixture lists first
// and uses whatever the mock returned so it stays referee-version-agnostic.
async function runHttpStandardHeadersClient(serverUrl: string): Promise<void> {
const client = await connectModernHeaderClient(serverUrl);
logger.debug('Successfully connected to MCP server');
const { tools } = await client.listTools();
const tool = tools[0];
if (tool) {
await client.callTool({ name: tool.name, arguments: {} });
}
const { resources } = await client.listResources();
const resource = resources[0];
if (resource) {
await client.readResource({ uri: resource.uri });
}
const { prompts } = await client.listPrompts();
const prompt = prompts[0];
if (prompt) {
await client.getPrompt({ name: prompt.name, arguments: {} });
}
await client.close();
logger.debug('Connection closed successfully');
}
registerScenario('http-standard-headers', runHttpStandardHeadersClient);
// ============================================================================
// SEP-2243 custom-header client scenarios (protocol revision 2026-07-28)
// ============================================================================
// The SEP-2243 conformance mocks (http-custom-headers / http-invalid-tool-headers)
// only implement tools/list + tools/call (and a 2025-shaped initialize pinned
// to 2026-07-28, no server/discover) — same connect-time gap as the
// multi-round-trip mock, so use the same withLocalDiscoverResponse fetch shim
// (defined below) to establish the modern era. The runner passes the exact
// tool calls to make via MCP_CONFORMANCE_CONTEXT.
function readToolCallsContext(): Array<{ name: string; arguments: Record<string, unknown> }> {
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
if (!raw) return [];
const parsed = JSON.parse(raw) as { toolCalls?: Array<{ name: string; arguments: Record<string, unknown> }> };
return parsed.toolCalls ?? [];
}
async function connectModernHeaderClient(serverUrl: string): Promise<Client> {
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: withLocalDiscoverResponse({ name: 'test-client', version: '1.0.0' })
});
await client.connect(transport);
return client;
}
// http-custom-headers: the conformance mock advertises test_custom_headers and
// test_custom_headers_null with x-mcp-header annotations. List first (so the
// SDK caches the inputSchema and can mirror), then make the runner-supplied
// calls; the conformance mock validates the Mcp-Param-* headers it receives.
async function runHttpCustomHeadersClient(serverUrl: string): Promise<void> {
const client = await connectModernHeaderClient(serverUrl);
const { tools } = await client.listTools();
logger.debug('listed tools:', tools.map(t => t.name).join(', '));
for (const call of readToolCallsContext()) {
await client.callTool({ name: call.name, arguments: call.arguments });
}
await client.close();
}
// http-invalid-tool-headers: the conformance mock advertises one valid tool
// alongside several constraint-violating ones. listTools() must exclude the
// invalid ones; the fixture then calls every tool that survived — a correct
// SDK leaves only valid_tool, so the mock records SUCCESS for the keep-valid
// check and SUCCESS for every excluded tool not having been called.
async function runHttpInvalidToolHeadersClient(serverUrl: string): Promise<void> {
const client = await connectModernHeaderClient(serverUrl);
const { tools } = await client.listTools();
logger.debug('post-exclusion tools:', tools.map(t => t.name).join(', '));
for (const tool of tools) {
await client.callTool({ name: tool.name, arguments: { region: 'us-west1' } }).catch(error => {
logger.debug(`call ${tool.name} rejected:`, String(error));
});
}
await client.close();
}
registerScenario('http-custom-headers', runHttpCustomHeadersClient);
registerScenario('http-invalid-tool-headers', runHttpInvalidToolHeadersClient);
// ============================================================================
// Multi-round-trip client scenario (SEP-2322, protocol revision 2026-07-28)
// ============================================================================
/**
* The multi-round-trip client scenario's mock server only implements
* `tools/list`, `tools/call` and `notifications/initialized`; it answers both
* `server/discover` and `initialize` with -32601, so neither connect-time
* negotiation path can establish the 2026-07-28 era against it. The scenario
* is pinned to 2026-07-28 (the runner resolves it there even on the
* default-version leg), so the fixture answers the connect-time
* `server/discover` probe locally through the transport's custom fetch and
* lets every other request reach the real mock. Everything the scenario
* measures — auto-fulfilment of the embedded elicitation, the byte-exact
* requestState echo, fresh JSON-RPC ids on retries, isolation of unrelated
* calls, and not retrying complete results — is the SDK driver's behavior
* against the real mock.
*/
function withLocalDiscoverResponse(serverInfo: { name: string; version: string }): typeof fetch {
return async (input, init) => {
if (typeof init?.body === 'string') {
try {
const message = JSON.parse(init.body) as { method?: string; id?: unknown };
if (message.method === 'server/discover') {
return Response.json(
{
jsonrpc: '2.0',
id: message.id,
result: {
supportedVersions: ['2026-07-28'],
// Advertise the full read surface so capability-gated
// list/read/get calls reach the real mock; callers that
// only use tools are unaffected by the extra entries.
capabilities: { tools: { listChanged: true }, resources: {}, prompts: {} },
serverInfo
}
},
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
} catch {
// Not a JSON-RPC body — fall through to the real fetch.
}
}
return fetch(input, init);
};
}
async function runMrtrClient(serverUrl: string): Promise<void> {
const clientInfo = { name: 'test-client', version: '1.0.0' };
const capabilities = { elicitation: {} };
const client = new Client(clientInfo, {
capabilities,
versionNegotiation: { mode: 'auto' }
});
// The auto-fulfilment driver dispatches the embedded elicitation requests
// to this handler, exactly like a server-initiated elicitation.
client.setRequestHandler('elicitation/create', async request => {
logger.debug('Fulfilling embedded elicitation request:', JSON.stringify(request.params));
return { action: 'accept' as const, content: { confirmed: true } };
});
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: withLocalDiscoverResponse(clientInfo)
});
await client.connect(transport);
logger.debug('Negotiated protocol version:', client.getNegotiatedProtocolVersion());
// requestState echo flow: the driver must echo the opaque state byte-exact
// and retry on a fresh JSON-RPC id.
const echoResult = await client.callTool({ name: 'test_mrtr_echo_state', arguments: {} });
logger.debug('test_mrtr_echo_state result:', JSON.stringify(echoResult));
// No-state flow: the InputRequiredResult carries no requestState, so the
// retry must not include one.
const noStateResult = await client.callTool({ name: 'test_mrtr_no_state', arguments: {} });
logger.debug('test_mrtr_no_state result:', JSON.stringify(noStateResult));
// Unrelated call: must not carry inputResponses or requestState from the
// multi-round-trip flows above.
const unrelatedResult = await client.callTool({ name: 'test_mrtr_unrelated', arguments: {} });
logger.debug('test_mrtr_unrelated result:', JSON.stringify(unrelatedResult));
// Result without resultType: the check passes as long as the client does
// not retry with inputResponses. The SDK treats a missing resultType from
// a 2026-negotiated server as a protocol violation and rejects locally
// without retrying, so this call is expected to throw.
try {
const noResultTypeResult = await client.callTool({ name: 'test_mrtr_no_result_type', arguments: {} });
logger.debug('test_mrtr_no_result_type result:', JSON.stringify(noResultTypeResult));
} catch (error) {
logger.debug('test_mrtr_no_result_type rejected locally (no retry):', error instanceof Error ? error.message : String(error));
}
await client.close();
logger.debug('Connection closed successfully');
}
registerScenario('sep-2322-client-request-state', runMrtrClient);
// ============================================================================
// Auth scenarios - well-behaved client
// ============================================================================
async function runAuthClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {} });
const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } });
const oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch);
@ -181,6 +471,10 @@ registerScenarios(
'auth/metadata-var3',
'auth/2025-03-26-oauth-metadata-backcompat',
'auth/2025-03-26-oauth-endpoint-fallback',
// RFC 8707 resource-indicator binding: the referee serves a PRM whose
// `resource` does not match the MCP server URL; the SDK's discovery path
// must reject before token exchange (the referee sets `allowClientError`).
'auth/resource-mismatch',
'auth/scope-from-www-authenticate',
'auth/scope-from-scopes-supported',
'auth/scope-omitted-when-undefined',
@ -190,7 +484,23 @@ registerScenarios(
'auth/token-endpoint-auth-post',
'auth/token-endpoint-auth-none',
'auth/offline-access-scope',
'auth/offline-access-not-supported'
'auth/offline-access-not-supported',
// SEP-2468 (RFC 9207 iss / RFC 8414 §3.3 issuer-echo). The well-behaved
// client captures `iss` from the authorization redirect and passes it to
// `auth()`; the SDK validates internally. Positive scenarios proceed to
// the token endpoint; negative scenarios throw `IssuerMismatchError` and
// the process exits with an error (the referee sets `allowClientError`).
'auth/iss-supported',
'auth/iss-not-advertised',
'auth/iss-supported-missing',
'auth/iss-wrong-issuer',
'auth/iss-unexpected',
'auth/iss-normalized',
'auth/metadata-issuer-mismatch',
// SEP-2352: PRM `authorization_servers` switches between calls; the client's
// issuer-stamped credential storage reads back as undefined at the new AS and
// re-registers there.
'auth/authorization-server-migration'
],
runAuthClient
);
@ -271,10 +581,16 @@ registerScenario('auth/client-credentials-basic', runClientCredentialsBasic);
* then exchanges the ID-JAG for an access token at the AS (RFC 7523 JWT bearer grant
* with client_secret_basic). The provider drives discovery + the JWT bearer step; the
* assertion callback handles the IdP exchange using the context-supplied ID token.
*
* The two scenarios share the same context shape and the same client behavior:
* `auth/cross-app-access-complete-flow` is the single-AS variant;
* `auth/enterprise-managed-authorization` is the SEP-990 extension scenario that
* additionally validates `requested_token_type=id-jag`, ID-JAG `typ` and
* `client_id`/`resource` claim binding at the AS.
*/
async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise<void> {
const ctx = parseContext();
if (ctx.name !== 'auth/cross-app-access-complete-flow') {
if (ctx.name !== 'auth/cross-app-access-complete-flow' && ctx.name !== 'auth/enterprise-managed-authorization') {
throw new Error(`Expected cross-app-access context, got ${ctx.name}`);
}
@ -311,6 +627,7 @@ async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise<void> {
}
registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccessCompleteFlow);
registerScenario('auth/enterprise-managed-authorization', runCrossAppAccessCompleteFlow);
// ============================================================================
// Pre-registration scenario (no dynamic client registration)
@ -451,6 +768,40 @@ async function runSSERetryClient(serverUrl: string): Promise<void> {
registerScenario('sse-retry', runSSERetryClient);
// ============================================================================
// JSON Schema $ref dereference scenario (SEP-2106)
// ============================================================================
/**
* The scenario serves a tool whose outputSchema carries a network `$ref`; the
* conformance check passes when the client lists tools without dereferencing
* (fetching) that URL. The SDK never dereferences network refs — output
* schemas are compiled lazily on the first `callTool()` against the cached
* `tools/list` entry, and the underlying engine (Ajv / cfworker) does not
* fetch external refs (Ajv throws `MissingRefError`, captured per-tool) — so
* a plain connect → listTools → close is sufficient: `listTools()` returns
* normally and the canary URL is never fetched.
*/
async function runJsonSchemaRefNoDerefClient(serverUrl: string): Promise<void> {
const client = new Client({ name: 'json-schema-ref-no-deref-client', version: '1.0.0' }, { capabilities: {} });
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug('Successfully connected to MCP server');
const tools = await client.listTools();
logger.debug(
'Available tools:',
tools.tools.map(t => t.name)
);
await transport.close();
logger.debug('Connection closed successfully');
}
registerScenario('json-schema-ref-no-deref', runJsonSchemaRefNoDerefClient);
// ============================================================================
// Main entry point
// ============================================================================
@ -488,9 +839,4 @@ async function main(): Promise<void> {
}
}
try {
await main();
} catch (error) {
logger.error('Error:', error);
process.exit(1);
}
await main();

View file

@ -10,9 +10,32 @@
import { randomUUID } from 'node:crypto';
import { localhostHostValidation } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server';
import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
import type {
CallToolResult,
EventId,
EventStore,
GetPromptResult,
InputRequests,
InputRequiredResult,
ReadResourceResult,
ServerContext,
StreamId
} from '@modelcontextprotocol/server';
import {
acceptedContent,
classifyInboundRequest,
CLIENT_CAPABILITIES_META_KEY,
createMcpHandler,
createRequestStateCodec,
fromJsonSchema,
inputRequired,
isInitializeRequest,
McpServer,
ProtocolError,
ProtocolErrorCode,
ResourceTemplate
} from '@modelcontextprotocol/server';
import cors from 'cors';
import type { Request, Response } from 'express';
import express from 'express';
@ -32,7 +55,9 @@ const eventStoreData = new Map<string, { eventId: string; message: unknown; stre
function createEventStore(): EventStore {
return {
async storeEvent(streamId: StreamId, message: unknown): Promise<EventId> {
const eventId = `${streamId}::${Date.now()}_${randomUUID()}`;
// Fixed-width timestamp so the lexicographic sort in
// replayEventsAfter is robustly chronological.
const eventId = `${streamId}::${String(Date.now()).padStart(15, '0')}_${randomUUID()}`;
eventStoreData.set(eventId, { eventId, message, streamId });
return eventId;
},
@ -64,6 +89,25 @@ const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQ
// Sample base64 encoded minimal WAV file for testing
const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=';
// ===== MULTI-ROUND-TRIP requestState INTEGRITY (SEP-2322) =====
//
// `requestState` round-trips through the client and comes back as
// attacker-controlled input. The SDK treats it as an opaque string and applies
// no protection of its own, so a server that lets it influence behavior MUST
// integrity-protect it when minting and MUST reject state that fails
// verification (see the migration guide). This fixture uses the SDK-provided
// `createRequestStateCodec` helper — `mint` HMAC-seals the payload with a
// per-process key and a TTL, and `verify` is the function dropped into
// `ServerOptions.requestState.verify` so the seam rejects tampered or expired
// state with `-32602` before the handler runs (which is what the
// `input-required-result-tampered-state` conformance scenario asserts). The
// key is process-local because the 2026-07-28 path serves every request from
// a fresh server instance — the state itself is the only thing that survives
// between rounds.
const requestStateCodec = createRequestStateCodec<Record<string, unknown>>({
key: crypto.getRandomValues(new Uint8Array(32))
});
// Function to create a new MCP server instance (one per session)
function createMcpServer() {
const mcpServer = new McpServer(
@ -83,9 +127,19 @@ function createMcpServer() {
prompts: {
listChanged: true
},
// `logging` is deprecated as of protocol version 2026-07-28
// (SEP-2577). Intentionally retained so the 2025-era
// logging/setLevel conformance leg still negotiates the
// capability; the 2026-07-28 path uses the per-request
// envelope and ignores this field.
logging: {},
completions: {}
}
},
// Seam-level integrity check (SEP-2322): every re-entered MRTR
// request that carries requestState is verified before the handler
// runs. A rejection answers a wire-level -32602 with
// data.reason 'invalid_request_state'.
requestState: { verify: requestStateCodec.verify }
}
);
@ -93,7 +147,7 @@ function createMcpServer() {
function sendLog(
level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency',
message: string,
_data?: unknown
data?: unknown
) {
mcpServer.server
.notification({
@ -101,7 +155,7 @@ function createMcpServer() {
params: {
level,
logger: 'conformance-test-server',
data: _data || message
data: data ?? message
}
})
.catch(() => {
@ -111,6 +165,27 @@ function createMcpServer() {
// ===== TOOLS =====
// SEP-2243 x-mcp-header tool — arms the http-custom-header-server-validation
// conformance scenario (which skips when no tool with an x-mcp-header
// annotation is found). The schema is hand-written JSON so the annotation
// survives serialization unchanged.
mcpServer.registerTool(
'test_x_mcp_header',
{
description: 'Tests SEP-2243 Mcp-Param-* server-side validation',
inputSchema: fromJsonSchema<{ region?: string; level?: number }>({
type: 'object',
properties: {
region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' },
level: { type: 'integer', description: 'non-mirrored argument' }
}
})
},
async (args): Promise<CallToolResult> => ({
content: [{ type: 'text', text: `region=${args.region ?? '<none>'}` }]
})
);
// Simple text tool
mcpServer.registerTool(
'test_simple_text',
@ -243,42 +318,28 @@ function createMcpServer() {
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult> => {
const progressToken = ctx.mcpReq._meta?.progressToken ?? 0;
console.log('Progress token:', progressToken);
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: {
progressToken,
progress: 0,
total: 100,
message: `Completed step ${0} of ${100}`
const progressToken = ctx.mcpReq._meta?.progressToken;
// Per spec, servers MUST NOT emit notifications/progress without a
// client-supplied token — only report progress when one was sent.
if (progressToken !== undefined) {
for (const progress of [0, 50, 100]) {
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: {
progressToken,
progress,
total: 100,
message: `Completed step ${progress} of ${100}`
}
});
if (progress < 100) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
});
await new Promise(resolve => setTimeout(resolve, 50));
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: {
progressToken,
progress: 50,
total: 100,
message: `Completed step ${50} of ${100}`
}
});
await new Promise(resolve => setTimeout(resolve, 50));
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: {
progressToken,
progress: 100,
total: 100,
message: `Completed step ${100} of ${100}`
}
});
}
return {
content: [{ type: 'text', text: String(progressToken) }]
content: [{ type: 'text', text: String(progressToken ?? 'no-progress-token') }]
};
}
);
@ -340,7 +401,7 @@ function createMcpServer() {
prompt: z.string().describe('The prompt to send to the LLM')
})
},
async (args: { prompt: string }, ctx): Promise<CallToolResult> => {
async (args, ctx): Promise<CallToolResult> => {
try {
// Request sampling from client
const result = (await ctx.mcpReq.send({
@ -391,7 +452,7 @@ function createMcpServer() {
message: z.string().describe('The message to show the user')
})
},
async (args: { message: string }, ctx): Promise<CallToolResult> => {
async (args, ctx): Promise<CallToolResult> => {
try {
// Request user input from client
const result = await ctx.mcpReq.send({
@ -597,22 +658,48 @@ function createMcpServer() {
}
);
// SEP-1613: JSON Schema 2020-12 conformance test tool
// SEP-1613 / SEP-2106: JSON Schema 2020-12 conformance test tool.
// The scenario verifies that $schema/$defs/additionalProperties (SEP-1613)
// and the broader 2020-12 vocabulary — $anchor, allOf/anyOf, if/then/else —
// (SEP-2106) survive tools/list verbatim. The schema is hand-authored JSON
// (via fromJsonSchema) so the keywords are advertised exactly as written;
// a Zod object would not emit them.
mcpServer.registerTool(
'json_schema_2020_12_tool',
{
description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)',
inputSchema: z.object({
name: z.string().optional(),
address: z
.object({
street: z.string().optional(),
city: z.string().optional()
})
.optional()
description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613, SEP-2106)',
inputSchema: fromJsonSchema({
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
$defs: {
address: {
$anchor: 'addressDef',
type: 'object',
properties: {
street: { type: 'string' },
city: { type: 'string' }
}
}
},
properties: {
name: { type: 'string' },
address: { $ref: '#/$defs/address' },
contactMethod: { type: 'string', enum: ['phone', 'email'] },
phone: { type: 'string' },
email: { type: 'string' }
},
allOf: [{ anyOf: [{ required: ['phone'] }, { required: ['email'] }] }],
if: {
properties: { contactMethod: { const: 'phone' } },
required: ['contactMethod']
},
// eslint-disable-next-line unicorn/no-thenable -- `then` is a JSON Schema 2020-12 keyword
then: { required: ['phone'] },
else: { required: ['email'] },
additionalProperties: false
})
},
async (args: { name?: string; address?: { street?: string; city?: string } }): Promise<CallToolResult> => {
async (args): Promise<CallToolResult> => {
return {
content: [
{
@ -624,6 +711,326 @@ function createMcpServer() {
}
);
// ===== MULTI-ROUND-TRIP TOOLS (SEP-2322, protocol revision 2026-07-28) =====
//
// Diagnostic tools for the input-required conformance scenarios. Each tool
// is written write-once style: it returns `inputRequired(...)` until the
// retried request carries the responses it needs (read from
// `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState`), then completes.
// These tools are only meaningful toward 2026-07-28 requests; calling them
// on a 2025-era session fails loudly at the server seam by design.
// Basic elicitation round trip. Also exercised by the result-type,
// missing-input-response, ignore-extra-params and validate-input
// scenarios: anything that does not contain an accepted "user_name"
// response is answered with a fresh InputRequiredResult re-requesting it.
mcpServer.registerTool(
'test_input_required_result_elicitation',
{
description: 'MRTR (SEP-2322): asks for the caller name via an in-band elicitation request',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'user_name')?.name;
if (typeof name !== 'string') {
return inputRequired({
inputRequests: {
user_name: inputRequired.elicit({
message: 'What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
})
}
});
}
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
}
);
// Basic sampling round trip.
mcpServer.registerTool(
'test_input_required_result_sampling',
{
description: 'MRTR (SEP-2322): asks for an LLM completion via an in-band sampling request',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const samplingResponse = ctx.mcpReq.inputResponses?.['capital_question'] as
| { content?: { type?: string; text?: string } }
| undefined;
if (samplingResponse === undefined) {
return inputRequired({
inputRequests: {
capital_question: inputRequired.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } }],
maxTokens: 100
})
}
});
}
const text =
typeof samplingResponse.content?.text === 'string' ? samplingResponse.content.text : JSON.stringify(samplingResponse);
return { content: [{ type: 'text', text: `Sampling response: ${text}` }] };
}
);
// Basic roots/list round trip.
mcpServer.registerTool(
'test_input_required_result_list_roots',
{
description: 'MRTR (SEP-2322): asks for the client roots via an in-band roots/list request',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const rootsResponse = ctx.mcpReq.inputResponses?.['client_roots'] as
| { roots?: Array<{ uri?: string; name?: string }> }
| undefined;
if (!Array.isArray(rootsResponse?.roots)) {
return inputRequired({ inputRequests: { client_roots: inputRequired.listRoots() } });
}
const uris = rootsResponse.roots.map(root => root.uri).join(', ');
return { content: [{ type: 'text', text: `Client exposed ${rootsResponse.roots.length} root(s): ${uris}` }] };
}
);
// requestState round trip: the state is integrity-protected when minted
// and verified on the retry (see the helpers above).
mcpServer.registerTool(
'test_input_required_result_request_state',
{
description: 'MRTR (SEP-2322): round-trips integrity-protected requestState alongside an elicitation request',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const confirmation = acceptedContent<{ ok: boolean }>(ctx.mcpReq.inputResponses, 'confirm');
if (confirmation === undefined) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: 'Please confirm',
requestedSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok']
}
})
},
requestState: await requestStateCodec.mint({ tool: 'request_state', nonce: randomUUID() })
});
}
// The seam-level verify hook has already proven integrity by the
// time the handler runs; calling `verify` again here just yields
// the payload (and would re-reject if it somehow had not).
const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx);
if (state === undefined) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Invalid requestState: missing or failed integrity verification');
}
return { content: [{ type: 'text', text: 'state-ok: requestState verified and confirmation received' }] };
}
);
// Multiple input requests of different kinds in one InputRequiredResult.
mcpServer.registerTool(
'test_input_required_result_multiple_inputs',
{
description: 'MRTR (SEP-2322): asks for elicitation, sampling and roots input in a single round',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const responses = ctx.mcpReq.inputResponses;
const name = acceptedContent<{ name: string }>(responses, 'user_name')?.name;
const greeting = (responses?.['greeting'] as { content?: { text?: string } } | undefined)?.content?.text;
const roots = (responses?.['client_roots'] as { roots?: unknown[] } | undefined)?.roots;
if (typeof name !== 'string' || typeof greeting !== 'string' || !Array.isArray(roots)) {
return inputRequired({
inputRequests: {
user_name: inputRequired.elicit({
message: 'What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
}),
greeting: inputRequired.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: 'Generate a greeting' } }],
maxTokens: 50
}),
client_roots: inputRequired.listRoots()
},
requestState: await requestStateCodec.mint({ tool: 'multiple_inputs', nonce: randomUUID() })
});
}
return { content: [{ type: 'text', text: `${greeting} ${name} — ${roots.length} root(s) visible` }] };
}
);
// Multi-round flow: the round number lives in the integrity-protected
// requestState (the 2026-07-28 path keeps no per-session state), and the
// state changes between rounds.
mcpServer.registerTool(
'test_input_required_result_multi_round',
{
description: 'MRTR (SEP-2322): two elicitation rounds with evolving requestState before completing',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
const state = ctx.mcpReq.requestState === undefined ? undefined : await requestStateCodec.verify(ctx.mcpReq.requestState, ctx);
const round = state?.tool === 'multi_round' && typeof state.round === 'number' ? state.round : 0;
if (round === 0) {
return inputRequired({
inputRequests: {
step1: inputRequired.elicit({
message: 'Step 1: What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
})
},
requestState: await requestStateCodec.mint({ tool: 'multi_round', round: 1, nonce: randomUUID() })
});
}
if (round === 1) {
const name = acceptedContent<{ name: string }>(ctx.mcpReq.inputResponses, 'step1')?.name ?? 'unknown';
return inputRequired({
inputRequests: {
step2: inputRequired.elicit({
message: 'Step 2: What is your favorite color?',
requestedSchema: {
type: 'object',
properties: { color: { type: 'string' } },
required: ['color']
}
})
},
requestState: await requestStateCodec.mint({ tool: 'multi_round', round: 2, name, nonce: randomUUID() })
});
}
const color = acceptedContent<{ color: string }>(ctx.mcpReq.inputResponses, 'step2')?.color ?? 'unknown';
return { content: [{ type: 'text', text: `Multi-round complete: ${String(state?.name ?? 'unknown')} likes ${color}` }] };
}
);
// Tampered-state rejection: the seam-level `requestState.verify` hook
// (the codec's `verify`, configured on the McpServer above) rejects a
// retry whose requestState fails HMAC before this handler runs, answering
// the wire-level -32602 the conformance scenario requires. The handler
// only sees verified state.
mcpServer.registerTool(
'test_input_required_result_tampered_state',
{
description: 'MRTR (SEP-2322): rejects retries whose requestState fails integrity verification',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
if (ctx.mcpReq.requestState !== undefined && acceptedContent(ctx.mcpReq.inputResponses, 'confirm') !== undefined) {
return { content: [{ type: 'text', text: 'integrity-ok: requestState verified' }] };
}
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: 'Please confirm',
requestedSchema: {
type: 'object',
properties: { ok: { type: 'boolean' } },
required: ['ok']
}
})
},
requestState: await requestStateCodec.mint({ tool: 'tampered_state', nonce: randomUUID() })
});
}
);
// Capability-aware input requests: only ask for kinds the request's
// declared client capabilities cover (the server seam enforces the same
// rule with a -32021 error; the tool simply never trips it).
mcpServer.registerTool(
'test_input_required_result_capabilities',
{
description: 'MRTR (SEP-2322): only requests input kinds the declared client capabilities cover',
inputSchema: z.object({})
},
async (_args, ctx): Promise<CallToolResult | InputRequiredResult> => {
if (ctx.mcpReq.inputResponses !== undefined) {
return { content: [{ type: 'text', text: 'Capability-aware input requests fulfilled' }] };
}
// `sampling` and `roots` on ClientCapabilities are @deprecated as
// of protocol version 2026-07-28 (SEP-2577). This fixture reads
// them intentionally: the conformance scenario asserts that the
// server only emits input-request kinds the client declared, and
// the per-request envelope carries the declared capabilities in
// the (deprecated) wire vocabulary.
const declared = ctx.mcpReq.envelope?.[CLIENT_CAPABILITIES_META_KEY];
const inputRequests: InputRequests = {};
if (declared?.elicitation !== undefined) {
inputRequests.user_name = inputRequired.elicit({
message: 'What is your name?',
requestedSchema: {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name']
}
});
}
if (declared?.sampling !== undefined) {
inputRequests.greeting = inputRequired.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: 'Generate a short greeting' } }],
maxTokens: 50
});
}
if (declared?.roots !== undefined) {
inputRequests.client_roots = inputRequired.listRoots();
}
if (Object.keys(inputRequests).length === 0) {
return { content: [{ type: 'text', text: 'No declared client capability supports an in-band input request' }] };
}
return inputRequired({ inputRequests });
}
);
// ===== SUBSCRIPTION/LISTEN DIAGNOSTIC TRIGGERS (SEP-2575) =====
//
// The `server-stateless` conformance scenario opens a `subscriptions/listen`
// stream (served by `createMcpHandler`'s built-in listen router), then calls
// one of these triggers and asserts the corresponding `*/list_changed`
// notification arrives on the open stream. The trigger publishes the change
// event onto the handler's bus via the `handler.notify.*` sugar — the
// listen router stamps the subscription id and applies the per-stream
// filter, so the same trigger also exercises the ack-first and
// honors-notification-filter checks. The 2026-07-28 path is per-request
// (each call gets a fresh `McpServer`), so there is no list to mutate; the
// event itself is what the SHOULD requirement measures.
mcpServer.registerTool(
'test_trigger_tool_change',
{
description: 'Listen diagnostic (SEP-2575): publishes a tools/list_changed event onto the handler bus',
inputSchema: z.object({})
},
async (): Promise<CallToolResult> => {
modernHandler.notify.toolsChanged();
return { content: [{ type: 'text', text: 'tools_list_changed published' }] };
}
);
mcpServer.registerTool(
'test_trigger_prompt_change',
{
description: 'Listen diagnostic (SEP-2575): publishes a prompts/list_changed event onto the handler bus',
inputSchema: z.object({})
},
async (): Promise<CallToolResult> => {
modernHandler.notify.promptsChanged();
return { content: [{ type: 'text', text: 'prompts_list_changed published' }] };
}
);
// ===== RESOURCES =====
// Static text resource
@ -703,7 +1110,7 @@ function createMcpServer() {
'test://watched-resource',
{
title: 'Watched Resource',
description: 'A resource that auto-updates every 3 seconds',
description: 'Static resource registered for subscribe/unsubscribe testing',
mimeType: 'text/plain'
},
async (): Promise<ReadResourceResult> => {
@ -769,7 +1176,7 @@ function createMcpServer() {
arg2: z.string().describe('Second test argument')
})
},
async (args: { arg1: string; arg2: string }): Promise<GetPromptResult> => {
async (args): Promise<GetPromptResult> => {
return {
messages: [
{
@ -794,7 +1201,7 @@ function createMcpServer() {
resourceUri: z.string().describe('URI of the resource to embed')
})
},
async (args: { resourceUri: string }): Promise<GetPromptResult> => {
async (args): Promise<GetPromptResult> => {
return {
messages: [
{
@ -820,6 +1227,47 @@ function createMcpServer() {
}
);
// Multi-round-trip prompt (SEP-2322): prompts/get is one of the methods
// whose 2026-07-28 result vocabulary includes input_required, so a prompt
// can request elicitation input in-band before rendering.
mcpServer.registerPrompt(
'test_input_required_result_prompt',
{
title: 'MRTR Prompt',
description: 'MRTR (SEP-2322): prompt that requires elicitation input before rendering'
},
async (ctx): Promise<GetPromptResult | InputRequiredResult> => {
// A prompt registered without argsSchema receives the request
// context as its only callback argument, but the registerPrompt
// overloads only model the (args, ctx) form — so the parameter
// arrives untyped and is narrowed here.
const promptCtx = ctx as ServerContext;
const promptContext = acceptedContent<{ context: string }>(promptCtx.mcpReq.inputResponses, 'user_context')?.context;
if (typeof promptContext !== 'string') {
return inputRequired({
inputRequests: {
user_context: inputRequired.elicit({
message: 'What context should the prompt use?',
requestedSchema: {
type: 'object',
properties: { context: { type: 'string' } },
required: ['context']
}
})
}
});
}
return {
messages: [
{
role: 'user',
content: { type: 'text', text: `Use the following context: ${promptContext}` }
}
]
};
}
);
// Prompt with image
mcpServer.registerPrompt(
'test_prompt_with_image',
@ -872,6 +1320,24 @@ function createMcpServer() {
return mcpServer;
}
// ===== 2026-07-28 (MODERN ERA) SERVING =====
// Modern-era traffic — requests claiming the per-request `_meta` envelope
// mechanism (SEP-2575), including `server/discover` and malformed variants of
// the claim — is served through `createMcpHandler`, backed by the same
// `createMcpServer()` fixture definition the 2025 sessions use. Legacy traffic
// never reaches this handler (see the routing in the POST handler below), so
// the 2025 stateful session path is unchanged.
const modernHandler = createMcpHandler(() => createMcpServer(), {
onerror: error => console.error('Modern-era MCP handler error:', error)
});
const modernNodeHandler = toNodeHandler(modernHandler);
/** Normalize a possibly-repeated HTTP header to its first value. */
function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
// ===== EXPRESS APP =====
const app = express();
@ -885,7 +1351,7 @@ app.use(
cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id']
allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version', 'mcp-method']
})
);
@ -894,6 +1360,23 @@ app.post('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
try {
// 2026-07-28 (modern era) traffic: anything claiming the per-request
// envelope mechanism — including malformed claims, which must get the
// modern validation-ladder errors rather than the 2025 session errors —
// is served by the createMcpHandler entry. Legacy-classified requests
// (initialize, no-claim traffic, batches, posted responses) fall
// through to the stateful 2025 session path below, untouched.
const inbound = classifyInboundRequest({
httpMethod: req.method,
protocolVersionHeader: headerValue(req.headers['mcp-protocol-version']),
mcpMethodHeader: headerValue(req.headers['mcp-method']),
body: req.body
});
if (inbound.kind !== 'legacy') {
await modernNodeHandler(req, res, req.body);
return;
}
let transport: NodeStreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {

View file

@ -3,15 +3,20 @@ import type {
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens
} from '@modelcontextprotocol/client';
export class ConformanceOAuthProvider implements OAuthClientProvider {
// Single-slot blob storage. The SDK stamps `issuer` onto saved values; round-tripping
// them unchanged means a credential issued by AS-A reads back as undefined at AS-B
// (SEP-2352) and the flow re-registers.
private _clientInformation?: OAuthClientInformationFull;
private _tokens?: OAuthTokens;
private _codeVerifier?: string;
private _authCode?: string;
private _authCodePromise?: Promise<string>;
private _iss?: string;
private _discoveryState?: OAuthDiscoveryState;
constructor(
private readonly _redirectUrl: string | URL,
@ -47,6 +52,21 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
this._tokens = tokens;
}
saveDiscoveryState(state: OAuthDiscoveryState): void {
this._discoveryState = state;
}
discoveryState(): OAuthDiscoveryState | undefined {
return this._discoveryState;
}
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
if (scope === 'all' || scope === 'client') this._clientInformation = undefined;
if (scope === 'all' || scope === 'tokens') this._tokens = undefined;
if (scope === 'all' || scope === 'verifier') this._codeVerifier = undefined;
if (scope === 'all' || scope === 'discovery') this._discoveryState = undefined;
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
try {
const response = await fetch(authorizationUrl.toString(), {
@ -58,6 +78,8 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
if (location) {
const redirectUrl = new URL(location);
const code = redirectUrl.searchParams.get('code');
// RFC 9207: capture `iss` alongside `code` for validation before token exchange.
this._iss = redirectUrl.searchParams.get('iss') ?? undefined;
if (code) {
this._authCode = code;
return;
@ -80,6 +102,11 @@ export class ConformanceOAuthProvider implements OAuthClientProvider {
throw new Error('No authorization code');
}
/** The `iss` parameter captured from the authorization callback (RFC 9207), or `undefined` if absent. */
getIss(): string | undefined {
return this._iss;
}
saveCodeVerifier(codeVerifier: string): void {
this._codeVerifier = codeVerifier;
}

View file

@ -1,5 +1,11 @@
import type { FetchLike, Middleware } from '@modelcontextprotocol/client';
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client';
import {
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isStrictScopeSuperset,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { ConformanceOAuthProvider } from './conformanceOAuthProvider';
@ -9,11 +15,26 @@ export const handle401 = async (
next: FetchLike,
serverUrl: string | URL
): Promise<void> => {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
const { resourceMetadataUrl, scope: challengedScope } = extractWWWAuthenticateParams(response);
// On a 403 insufficient_scope step-up, request the union of the previously
// granted scope and the challenged scope so the existing permissions are
// preserved (SEP-2350). On the initial 401 there is no prior token, so the
// union degenerates to the challenged scope.
const previousTokens = await provider.tokens();
const scope = response.status === 403 ? computeScopeUnion(previousTokens?.scope, challengedScope) : challengedScope;
// A 401 after we already held a token means it no longer authenticates the resource;
// drop cached discovery so auth() re-probes PRM and can detect an authorization-server
// migration (SEP-2352). 403 is a step-up at the same AS — keep the cache.
if (response.status === 401) {
provider.invalidateCredentials('discovery');
}
let result = await auth(provider, {
serverUrl,
resourceMetadataUrl,
scope,
// SEP-2350: when the union strictly exceeds the current token's granted scope,
// a refresh cannot widen it (RFC 6749 §6) — bypass refresh and re-authorize.
forceReauthorization: isStrictScopeSuperset(scope, previousTokens?.scope),
fetchFn: next
});
@ -25,6 +46,7 @@ export const handle401 = async (
// await provider.waitForCallback();
const authorizationCode = await provider.getAuthCode();
const iss = provider.getIss();
// TODO: this retry logic should be incorporated into the typescript SDK
result = await auth(provider, {
@ -32,6 +54,7 @@ export const handle401 = async (
resourceMetadataUrl,
scope,
authorizationCode,
iss,
fetchFn: next
});
if (result !== 'AUTHORIZED') {
@ -47,8 +70,11 @@ export const handle401 = async (
* - Does not throw UnauthorizedError on redirect, but instead retries
* - Calls next() instead of throwing for redirect-based auth
*
* @param provider - OAuth client provider for authentication
* @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain)
* @param clientName - `client_name` for the auto-created ConformanceOAuthProvider (ignored when `existingProvider` is supplied)
* @param baseUrl - Base URL for OAuth server discovery (defaults to request URL origin)
* @param handle401Fn - Challenge handler invoked on 401/403 (defaults to {@link handle401})
* @param clientMetadataUrl - CIMD URL for the auto-created provider (ignored when `existingProvider` is supplied)
* @param existingProvider - Pre-populated provider; when set, `clientName`/`clientMetadataUrl` are unused
* @returns A fetch middleware function
*/
export const withOAuthRetry = (
@ -84,7 +110,7 @@ export const withOAuthRetry = (
let response = await makeRequest();
// Handle 401 responses by attempting re-authentication
// Handle 401/403 responses by attempting re-authentication
if (response.status === 401 || response.status === 403) {
const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin);
await handle401Fn(response, provider, next, serverUrl);
@ -92,7 +118,7 @@ export const withOAuthRetry = (
response = await makeRequest();
}
// If we still have a 401 after re-auth attempt, throw an error
// If we still have a 401/403 after re-auth attempt, throw an error
if (response.status === 401 || response.status === 403) {
const url = typeof input === 'string' ? input : input.toString();
throw new UnauthorizedError(`Authentication failed for ${url}`);

View file

@ -58,6 +58,27 @@ note: 'stateless hosting has no server→client back-channel'
`addedInSpecVersion` / `removedInSpecVersion` bound the spec versions a requirement applies to. A behavior changed by a spec release gets a sibling entry: the new entry lists every retired id it replaces in `supersedes` (an array, requires `addedInSpecVersion`), and each retired
entry points back via `supersededBy` (requires `removedInSpecVersion`). A coverage gate enforces that the links resolve and are exactly symmetric.
## The createMcpHandler entry arms (entryStateless / entryModern)
Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis:
- `entryStateless` — the entry with its stateless legacy fallback (`legacy: 'stateless'`, the entry's default posture, passed explicitly so the arm stays era-pinned); the scenario's plain client is served per request through the fallback. Cells run on the 2025-11-25 axis only.
- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the arm pins the scenario's client to the 2026-07-28 revision via `setVersionNegotiation()`, and the client attaches the per-request `_meta` envelope to every outgoing request/notification itself. Cells
run on the 2026-07-28 axis only. The pin is unconditional, so a scenario that needs to assert non-pin negotiation behavior (e.g. `mode: 'auto'` probing) must restrict off `entryModern` or drive a non-entry transport.
Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test:
```ts
entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' /* optional note */ }];
```
Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the
per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams) are already expressed through their `transports` restrictions and need no annotation.
Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a different `legacy` posture), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable response
clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. Compositions that the entry no longer expresses through
an option (for example an existing sessionful legacy wiring routed via `isLegacyRequest` next to a strict entry) are hosted by the test body itself behind an in-process fetch — see `scenarios/hosting-entry-session.test.ts`.
## Running
From the repo root (the suite is the `@modelcontextprotocol/test-e2e` workspace package):

View file

@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
import { expect, test } from 'vitest';
import { REQUIREMENTS } from './requirements';
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from './types';
const E2E_DIR = path.dirname(fileURLToPath(import.meta.url));
@ -88,6 +89,34 @@ test('every transport-restricted requirement explains why in note', () => {
expect(missing).toEqual([]);
});
test('every entryExclusions annotation targets an entry arm the requirement would otherwise run on', () => {
const bad: string[] = [];
for (const [id, r] of Object.entries(REQUIREMENTS)) {
for (const exclusion of r.entryExclusions ?? []) {
const arms = exclusion.arm === undefined ? ENTRY_TRANSPORTS : [exclusion.arm];
for (const arm of arms) {
const transports = r.transports ?? ALL_TRANSPORTS;
if (!transports.includes(arm)) {
bad.push(`${id}: entryExclusions targets '${arm}', which the requirement's transports never include`);
continue;
}
const versions = ALL_SPEC_VERSIONS.filter(
v =>
(r.addedInSpecVersion === undefined || v >= r.addedInSpecVersion) &&
(r.removedInSpecVersion === undefined || v < r.removedInSpecVersion) &&
(TRANSPORT_SPEC_VERSIONS[arm]?.includes(v) ?? true)
);
if (versions.length === 0) {
bad.push(
`${id}: entryExclusions targets '${arm}', which registers no cells within the requirement's spec-version bounds`
);
}
}
}
}
expect(bad).toEqual([]);
});
test('supersedes/supersededBy links are symmetric and resolve', () => {
const bad: string[] = [];
for (const [id, req] of Object.entries(REQUIREMENTS)) {

View file

@ -0,0 +1,28 @@
/**
* Runnable dual-era stdio MCP server fixture for the dual-era stdio e2e cells.
*
* The connection-pinned `serveStdio` entry over an ordinary `McpServer`
* factory: the client's opening exchange selects the era for the connection
* (a 2025 `initialize` handshake or 2026-07-28 per-request envelope traffic
* negotiated via `server/discover`), and one factory instance serves it.
* Spawned as a real child process (via tsx) by
* test/e2e/scenarios/stdio-dual-era.test.ts; exits when its stdin reaches EOF.
*/
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { z } from 'zod/v4';
serveStdio(() => {
const server = new McpServer({ name: 'dual-era-stdio-e2e-fixture', version: '1.0.0' }, { capabilities: { tools: {} } });
server.registerTool(
'echo',
{
description: 'Echoes the input text back as a text content block.',
inputSchema: z.object({ text: z.string() })
},
({ text }) => ({ content: [{ type: 'text', text }] })
);
return server;
});
process.stderr.write('[dual-era-stdio-server] ready\n');

View file

@ -15,30 +15,101 @@ import { PassThrough } from 'node:stream';
import type { Client } from '@modelcontextprotocol/client';
import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server';
import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import type {
CreateMcpHandlerOptions,
EventStore,
Implementation,
JSONRPCMessage,
McpRequestContext,
McpServer,
Server
} from '@modelcontextprotocol/server';
import {
createMcpHandler,
InMemoryTransport,
ReadBuffer,
serializeMessage,
WebStandardStreamableHTTPServerTransport
} from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import type { Transport } from '../types';
import type { SpecVersion, Transport } from '../types';
import { startLegacySseHost } from './sse-host';
import type { SnifferOptions } from './wire-sniffer';
import { sniffTransport } from './wire-sniffer';
/** Narrows away `null`/`undefined` for values the surrounding test has already proven exist (replaces non-null assertions). */
export function defined<T>(value: T | null | undefined, label: string): NonNullable<T> {
if (value === null || value === undefined) throw new Error(`expected ${label} to be defined`);
return value;
}
export type ServerFactory = () => McpServer | Server;
/**
* A factory that optionally consumes the createMcpHandler per-request context.
* The context is only supplied on the entry arms (where the entry constructs a
* fresh instance per request); on every other arm the factory is called with no
* arguments, so declare the parameter optional.
*/
export type EntryServerFactory = (ctx?: McpRequestContext) => McpServer | Server;
/** One HTTP exchange recorded by the entry arms (see {@linkcode Wired.httpLog}). */
export interface RecordedHttpExchange {
/** HTTP request method (GET/POST/DELETE). */
method: string;
/** The HTTP request headers as resolved by `new Request(...)` — for raw header assertions (e.g. `Mcp-Param-*`). */
requestHeaders: Headers;
/** The request body text, when one was sent as a string. */
requestBody?: string;
/** HTTP response status. */
status: number;
/** Response content-type header (empty string when absent). */
contentType: string;
/** An unread clone of the HTTP response, for byte-level assertions (`await exchange.response.text()`). */
response: Response;
}
export interface Wired extends AsyncDisposable {
readonly fetch?: (url: URL | string, init?: RequestInit) => Promise<Response>;
readonly url?: URL;
/**
* Every HTTP exchange the wired client performed, in order, including the
* connect-time negotiation. Recorded by the createMcpHandler entry arms
* only — scenarios on those arms use it to assert raw wire facts (request
* bodies, response status/content-type/bytes) that the typed client API
* does not expose.
*/
readonly httpLog?: readonly RecordedHttpExchange[];
}
/**
* The fourth argument controls the wire-format sniffer (see wire-sniffer.ts):
* every message the client sends or receives is validated against the SDK's
* spec-anchored Zod schemas. Tests that intentionally use vendor-extension
* methods pass `{ allowCustomMethods: true }`; tests that deliberately put
* malformed MCP on the wire pass `{ strictValidation: false }`.
* The fourth argument's sniffer options control the wire-format sniffer (see
* wire-sniffer.ts): every message the client sends or receives is validated
* against the SDK's spec-anchored Zod schemas. Tests that intentionally use
* vendor-extension methods pass `{ allowCustomMethods: true }`; tests that
* deliberately put malformed MCP on the wire pass `{ strictValidation: false }`.
* `entry` overrides the hosting options of the createMcpHandler entry arms
* (ignored by every other transport).
*/
export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise<Wired> {
export interface WireOptions extends SnifferOptions {
/**
* createMcpHandler hosting overrides for the entry arms. Defaults:
* `{ legacy: 'stateless' }` on entryStateless (the entry's default posture,
* passed explicitly so the arm stays pinned to the 2025 leg even if the
* default ever moves) and `{ legacy: 'reject' }` (modern-only strict) on
* entryModern. `onerror` and `responseMode` pass through unchanged.
*/
entry?: CreateMcpHandlerOptions;
}
export async function wire(
transport: Transport,
makeServer: ServerFactory | EntryServerFactory,
client: Client,
sniff: WireOptions = {}
): Promise<Wired> {
switch (transport) {
case 'inMemory': {
const server = makeServer();
@ -67,6 +138,57 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
};
}
case 'entryStateless':
case 'entryModern': {
// The dual-era HTTP entry (`createMcpHandler`) hosted in process via an
// injected fetch, exactly like the other HTTP arms. The scenario factory
// backs the entry directly (the entry calls it once per request with its
// per-request context). `entryStateless` serves the scenario's plain
// client through the entry's stateless legacy fallback (the default,
// passed explicitly to keep the arm era-pinned); `entryModern` hosts the
// endpoint modern-only strict (`legacy: 'reject'` — strict is no longer
// the entry default) and pins the scenario's client to the 2026-07-28
// revision via the public negotiation setter. The client attaches the
// per-request `_meta` envelope itself once a modern era is negotiated,
// so no harness wrap is needed. Every HTTP exchange is recorded on
// `httpLog`.
const handler = createMcpHandler(
makeServer,
transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { legacy: 'reject', ...sniff.entry }
);
const url = new URL('http://in-process/mcp');
const httpLog: RecordedHttpExchange[] = [];
const fetch = async (u: URL | string, init?: RequestInit) => {
const request = new Request(u, init);
const response = await handler.fetch(request);
httpLog.push({
method: request.method.toUpperCase(),
requestHeaders: request.headers,
...(typeof init?.body === 'string' && { requestBody: init.body }),
status: response.status,
contentType: response.headers.get('content-type') ?? '',
response: response.clone()
});
return response;
};
const clientTx = new StreamableHTTPClientTransport(url, { fetch });
// entryModern is the era-fixed 2026-07-28 arm: it is the only arm
// whose wire may legitimately carry input_required results, so it
// opts the sniffer into accepting them (other arms stay strict).
let armSniff: WireOptions = sniff;
if (transport === 'entryModern') {
client.setVersionNegotiation({ mode: { pin: MODERN_REVISION } });
armSniff = { allowInputRequiredResults: true, ...sniff };
}
await client.connect(sniffTransport(clientTx, 'client', armSniff));
if (transport === 'entryModern') assertModernNegotiation(client);
return {
fetch,
url,
httpLog,
[Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {})
};
}
case 'sse': {
// The legacy SSE transport needs a real socket: the factory's server is hosted on the
// shipped SSEServerTransport (@modelcontextprotocol/server-legacy/sse) behind a loopback
@ -212,6 +334,44 @@ export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpH
};
}
// ───────────────────────────────────────────────────────────────────────────────
// createMcpHandler entry arms (entryStateless / entryModern) — client-side shims
// ───────────────────────────────────────────────────────────────────────────────
/** The protocol revision the entryModern arm negotiates and claims per request. */
const MODERN_REVISION: SpecVersion = '2026-07-28';
/**
* The per-request `_meta` envelope of a 2026-07-28 request, for scenario bodies
* that put raw HTTP requests on the wire (via `wired.fetch`) rather than going
* through the wired client. Typed calls through the wired client never need
* this — the client attaches the envelope itself once a modern era is
* negotiated.
*/
export function modernEnvelopeMeta(clientInfo?: Implementation): Record<string, unknown> {
return {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: clientInfo ?? { name: 'e2e-entry-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
}
/**
* Fail fast if an entryModern connection did not actually negotiate the
* 2026-07-28 revision. Every cell on the arm asserts modern-path behavior, so
* a broken negotiation pin (or a regression in the discover negotiation) would
* otherwise surface as hundreds of unrelated downstream assertion failures;
* this turns it into one attributable arm-level error right after connect.
*/
function assertModernNegotiation(client: Client): void {
const negotiated = client.getNegotiatedProtocolVersion();
if (negotiated !== MODERN_REVISION) {
throw new Error(
`entryModern arm: expected the connection to negotiate protocol version ${MODERN_REVISION}, but it negotiated ${negotiated ?? 'no version'}`
);
}
}
// ───────────────────────────────────────────────────────────────────────────────
// In-process stdio client — TEST-ONLY
//

View file

@ -18,11 +18,23 @@
import { describe, test } from 'vitest';
import { REQUIREMENTS } from '../requirements';
import type { TestArgs } from '../types';
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types';
import type { Requirement, SpecVersion, TestArgs, Transport } from '../types';
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, ENTRY_TRANSPORTS, TRANSPORT_SPEC_VERSIONS } from '../types';
type TestBody = (args: TestArgs) => Promise<void>;
/** Whether a requirement's `entryExclusions` keep the given entry arm out of its cells. */
function excludedFromEntryArm(req: Requirement, transport: Transport): boolean {
if (!(ENTRY_TRANSPORTS as readonly Transport[]).includes(transport)) return false;
return (req.entryExclusions ?? []).some(x => x.arm === undefined || x.arm === transport);
}
/** Whether a transport arm serves the given spec version (era-fixed arms serve exactly one). */
function transportServesVersion(transport: Transport, version: SpecVersion): boolean {
const versions = TRANSPORT_SPEC_VERSIONS[transport];
return versions === undefined || versions.includes(version);
}
export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void {
const ids = Array.isArray(id) ? id : [id];
for (const rid of ids) registerOne(rid, fn, opts);
@ -33,13 +45,13 @@ function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void
if (!req) throw new Error(`verifies('${id}'): unknown requirement id`);
if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`);
const transports = req.transports ?? ALL_TRANSPORTS;
const transports = (req.transports ?? ALL_TRANSPORTS).filter(t => !excludedFromEntryArm(req, t));
const versions = ALL_SPEC_VERSIONS.filter(
v =>
(req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) &&
(req.removedInSpecVersion === undefined || v < req.removedInSpecVersion)
);
const cells = versions.flatMap(v => transports.map(t => [t, v] as const));
const cells = versions.flatMap(v => transports.filter(t => transportServesVersion(t, v)).map(t => [t, v] as const));
describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => {
const kf = req.knownFailures?.find(

View file

@ -61,6 +61,19 @@ describe('assertWireMessage', () => {
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow();
});
it('rejects an input_required server result unless the cell opted in (modern-era arms only)', () => {
const inputRequired = resp({
resultType: 'input_required',
inputRequests: { ask: { method: 'elicitation/create', params: { mode: 'form', message: 'Name?' } } }
});
// Default (legacy-era cells): input_required is not legal wire vocabulary.
expect(() => assertWireMessage(inputRequired, 'server')).toThrow(/invalid message/);
// Modern-era arms opt in explicitly.
expect(() => assertWireMessage(inputRequired, 'server', { allowInputRequiredResults: true })).not.toThrow();
// The opt-in never applies to client-sent results.
expect(() => assertWireMessage(inputRequired, 'client', { allowInputRequiredResults: true })).toThrow(/invalid message/);
});
it('accepts a JSON-RPC error response for either party', () => {
const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } };
expect(() => assertWireMessage(err, 'server')).not.toThrow();

View file

@ -8,6 +8,7 @@ import {
} from '@modelcontextprotocol/core-internal';
import type { Transport } from '@modelcontextprotocol/server';
import {
isInputRequiredResult,
isJSONRPCErrorResponse,
isJSONRPCNotification,
isJSONRPCRequest,
@ -22,6 +23,13 @@ export interface SnifferOptions {
allowCustomMethods?: boolean;
/** `false` → envelope check only (for tests that deliberately send malformed messages). */
strictValidation?: boolean;
/**
* Permit `input_required` results as server output. Set automatically by
* the wiring for the modern-era (2026-07-28) arms — multi-round-trip
* results are not legal vocabulary on the 2025-era wire, so an
* `input_required` leaking onto a legacy cell is flagged.
*/
allowInputRequiredResults?: boolean;
}
const OUTBOUND = {
@ -87,6 +95,12 @@ export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferO
if (isJSONRPCResultResponse(msg)) {
const result = (msg as { result: unknown }).result;
// Multi-round-trip results (protocol revision 2026-07-28) are valid
// server output but deliberately NOT part of the neutral result union
// (InputRequiredResultSchema lives alongside, never widening it).
// Era-gated: only cells wired for the modern era opt in, so an
// input_required on a 2025-era cell's wire is still flagged.
if (party === 'server' && opts.allowInputRequiredResults === true && isInputRequiredResult(result)) return;
const r = schemas.result.safeParse(result);
if (!r.success) {
// A result for a vendor-extension request legitimately won't match the spec union.

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

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