Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
51 changed files with 69131 additions and 9001 deletions
|
|
@ -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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
34
test/conformance/expected-failures.2026-07-28.yaml
Normal file
34
test/conformance/expected-failures.2026-07-28.yaml
Normal 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.)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:^",
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
28
test/e2e/fixtures/dual-era-stdio-server.ts
Normal file
28
test/e2e/fixtures/dual-era-stdio-server.ts
Normal 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');
|
||||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -8,20 +8,29 @@
|
|||
|
||||
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
||||
|
||||
import type { AuthProvider, OAuthClientProvider } from '@modelcontextprotocol/client';
|
||||
import type { AuthProvider, OAuthClientProvider, OAuthDiscoveryState } from '@modelcontextprotocol/client';
|
||||
import {
|
||||
applyMiddlewares,
|
||||
auth,
|
||||
AuthorizationServerMismatchError,
|
||||
Client,
|
||||
ClientCredentialsProvider,
|
||||
computeScopeUnion,
|
||||
createMiddleware,
|
||||
discoverAuthorizationServerMetadata,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
exchangeAuthorization,
|
||||
InsecureTokenEndpointError,
|
||||
InsufficientScopeError,
|
||||
isStrictScopeSuperset,
|
||||
IssuerMismatchError,
|
||||
OAuthError,
|
||||
OAuthErrorCode,
|
||||
PrivateKeyJwtProvider,
|
||||
refreshAuthorization,
|
||||
registerClient,
|
||||
RegistrationRejectedError,
|
||||
resolveClientMetadata,
|
||||
SdkError,
|
||||
SSEClientTransport,
|
||||
SseError,
|
||||
|
|
@ -29,6 +38,7 @@ import {
|
|||
StaticPrivateKeyJwtProvider,
|
||||
StreamableHTTPClientTransport,
|
||||
UnauthorizedError,
|
||||
validateAuthorizationResponseIssuer,
|
||||
withLogging,
|
||||
withOAuth
|
||||
} from '@modelcontextprotocol/client';
|
||||
|
|
@ -36,14 +46,17 @@ import type {
|
|||
AuthorizationServerMetadata,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthTokens
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
StoredOAuthClientInformation,
|
||||
StoredOAuthTokens
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { LATEST_PROTOCOL_VERSION, McpServer } from '@modelcontextprotocol/server';
|
||||
import { importSPKI, jwtVerify } from 'jose';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostPerSession } from '../helpers/index';
|
||||
import { defined, hostPerSession } from '../helpers/index';
|
||||
import { verifies } from '../helpers/verifies';
|
||||
import type { TestArgs } from '../types';
|
||||
|
||||
|
|
@ -51,16 +64,11 @@ const ISSUER = 'https://auth.example.com';
|
|||
const MCP_URL = 'http://in-process/mcp';
|
||||
const RESOURCE = 'http://in-process/mcp';
|
||||
|
||||
// Narrows indexed-access results that the surrounding count assertions have already proven to exist.
|
||||
function defined<T>(value: T | undefined, label: string): T {
|
||||
if (value === undefined) throw new Error(`Expected ${label} to be defined`);
|
||||
return value;
|
||||
}
|
||||
|
||||
interface MockASConfig {
|
||||
tokenResponses?: Array<Partial<OAuthTokens>>;
|
||||
tokenErrorResponses?: Array<{ error: string; error_description?: string }>;
|
||||
registerResponse?: Partial<OAuthClientInformationFull>;
|
||||
registerErrorResponse?: { status: number; error: string; error_description?: string };
|
||||
asMetadata?: Partial<AuthorizationServerMetadata>;
|
||||
prmMetadata?: Record<string, unknown>;
|
||||
noPRMDiscovery?: boolean;
|
||||
|
|
@ -155,6 +163,10 @@ function createMockAuthorizationServer(config: MockASConfig = {}) {
|
|||
if (path === '/register' && req.method === 'POST') {
|
||||
const body = z.record(z.string(), z.unknown()).parse(await req.json());
|
||||
registerCalls.push({ body });
|
||||
if (config.registerErrorResponse) {
|
||||
const { status, ...err } = config.registerErrorResponse;
|
||||
return Response.json(err, { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
// RFC 7591: the registration response echoes the submitted metadata plus issued credentials.
|
||||
const response = {
|
||||
...body,
|
||||
|
|
@ -190,6 +202,7 @@ class RecordingOAuthClientProvider implements OAuthClientProvider {
|
|||
tokens?: OAuthTokens;
|
||||
clientInformation?: OAuthClientInformationMixed;
|
||||
clientMetadataUrl?: string;
|
||||
clientMetadata?: Partial<OAuthClientMetadata>;
|
||||
} = {}
|
||||
) {
|
||||
if (initial.tokens) this.saved.tokens = initial.tokens;
|
||||
|
|
@ -204,10 +217,11 @@ class RecordingOAuthClientProvider implements OAuthClientProvider {
|
|||
return this.initial.clientMetadataUrl;
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
client_name: 'Test Client',
|
||||
redirect_uris: [this.redirectUrl]
|
||||
redirect_uris: [this.redirectUrl],
|
||||
...this.initial.clientMetadata
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -417,12 +431,13 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => {
|
|||
await interactiveClient.close();
|
||||
}
|
||||
|
||||
// Phase 2: when the upscoped token is still rejected with the same header, the transport stops instead of looping.
|
||||
// Phase 2: when the upscoped token is still rejected, the transport stops at the per-send retry cap instead of looping.
|
||||
// The token here already covers the challenged scope, so refresh (not a fresh authorization) is used.
|
||||
const refreshAs = createMockAuthorizationServer({
|
||||
tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer' }]
|
||||
tokenResponses: [{ access_token: 'upscoped-access-token', token_type: 'Bearer', scope: UPGRADED_SCOPE }]
|
||||
});
|
||||
const refreshProvider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token' },
|
||||
tokens: { access_token: 'narrow-scope-token', token_type: 'Bearer', refresh_token: 'narrow-refresh-token', scope: UPGRADED_SCOPE },
|
||||
clientInformation: { client_id: 'pre-registered-client' }
|
||||
});
|
||||
|
||||
|
|
@ -445,7 +460,7 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => {
|
|||
try {
|
||||
const connectPromise = refreshClient.connect(refreshTransport);
|
||||
await expect(connectPromise).rejects.toBeInstanceOf(SdkError);
|
||||
await expect(connectPromise).rejects.toThrow(/403 after trying upscoping/);
|
||||
await expect(connectPromise).rejects.toThrow(/403 insufficient_scope after step-up re-authorization/);
|
||||
|
||||
expect(refreshAs.tokenCalls).toHaveLength(1);
|
||||
expect(defined(refreshAs.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token');
|
||||
|
|
@ -455,6 +470,194 @@ verifies('client-auth:403-scope-upgrade', async (_args: TestArgs) => {
|
|||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:stepup:scope-union', async (_args: TestArgs) => {
|
||||
// computeScopeUnion is a deliberate public export.
|
||||
expect(computeScopeUnion('files:read openid', 'files:write')).toBe('files:read openid files:write');
|
||||
expect(computeScopeUnion('admin', 'read')).toBe('admin read'); // no hierarchical collapse
|
||||
|
||||
// The transport requests the union of its previously-requested scope and the
|
||||
// newly-challenged scope on step-up.
|
||||
const PREVIOUS_SCOPE = 'files:read openid';
|
||||
const CHALLENGED_SCOPE = 'files:write';
|
||||
const as = createMockAuthorizationServer();
|
||||
const provider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 'read-token', token_type: 'Bearer', scope: PREVIOUS_SCOPE },
|
||||
clientInformation: { client_id: 'pre-registered-client' }
|
||||
});
|
||||
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
|
||||
return as.handleRequest(new Request(url, init));
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${CHALLENGED_SCOPE}"` }
|
||||
});
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
try {
|
||||
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
|
||||
expect(provider.redirectedTo).toHaveLength(1);
|
||||
const redirect = defined(provider.redirectedTo[0], 'authorize URL');
|
||||
expect(redirect.searchParams.get('scope')).toBe('files:read openid files:write');
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies(['client-auth:stepup:retry-cap', 'client-auth:stepup:refresh-bypass-on-superset'], async (_args: TestArgs) => {
|
||||
// Part A — superset bypass: token granted "files:read"; challenged scope adds
|
||||
// "files:write". Union strictly exceeds the token's grant → auth() forces a
|
||||
// fresh authorization request (no refresh-token POST observed).
|
||||
{
|
||||
const as = createMockAuthorizationServer();
|
||||
const provider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read' },
|
||||
clientInformation: { client_id: 'pre-registered-client' }
|
||||
});
|
||||
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
|
||||
return as.handleRequest(new Request(url, init));
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' }
|
||||
});
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
try {
|
||||
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
|
||||
// Refresh was bypassed: no token-endpoint POST; the fresh authorize
|
||||
// request carries the union scope.
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
expect(provider.redirectedTo).toHaveLength(1);
|
||||
expect(defined(provider.redirectedTo[0], 'authorize URL').searchParams.get('scope')).toBe('files:read files:write');
|
||||
expect(isStrictScopeSuperset('files:read files:write', 'files:read')).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Part B — refresh used + retry cap: token already covers the challenged
|
||||
// scope (server is misconfigured / hierarchical). Union is NOT a strict
|
||||
// superset → refresh is used. Server keeps 403'ing → per-send retry cap
|
||||
// (default 1) stops the loop after exactly one step-up.
|
||||
{
|
||||
const as = createMockAuthorizationServer({
|
||||
tokenResponses: [{ access_token: 't2', token_type: 'Bearer', scope: 'files:read files:write' }]
|
||||
});
|
||||
const provider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 't', token_type: 'Bearer', refresh_token: 'rt', scope: 'files:read files:write' },
|
||||
clientInformation: { client_id: 'pre-registered-client' }
|
||||
});
|
||||
const mcpPosts: string[] = [];
|
||||
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
|
||||
return as.handleRequest(new Request(url, init));
|
||||
}
|
||||
mcpPosts.push(urlObj.pathname);
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="files:write"' }
|
||||
});
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
try {
|
||||
const connectPromise = client.connect(transport);
|
||||
await expect(connectPromise).rejects.toBeInstanceOf(SdkError);
|
||||
await expect(connectPromise).rejects.toThrow(/retry limit 1 reached/);
|
||||
expect(as.tokenCalls).toHaveLength(1);
|
||||
expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token');
|
||||
expect(provider.redirectedTo).toHaveLength(0);
|
||||
expect(mcpPosts).toHaveLength(2);
|
||||
expect(isStrictScopeSuperset('files:read files:write', 'files:read files:write')).toBe(false);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:stepup:throw-mode', async (_args: TestArgs) => {
|
||||
const as = createMockAuthorizationServer();
|
||||
const provider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 't', token_type: 'Bearer' },
|
||||
clientInformation: { client_id: 'pre-registered-client' }
|
||||
});
|
||||
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const urlObj = typeof url === 'string' ? new URL(url) : url;
|
||||
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
|
||||
return as.handleRequest(new Request(url, init));
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
headers: {
|
||||
'WWW-Authenticate': `Bearer error="insufficient_scope", scope="files:write", resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource", error_description="write permission required"`
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
authProvider: provider,
|
||||
fetch: combinedFetch,
|
||||
onInsufficientScope: 'throw'
|
||||
});
|
||||
try {
|
||||
const connectPromise = client.connect(transport);
|
||||
await expect(connectPromise).rejects.toBeInstanceOf(InsufficientScopeError);
|
||||
await expect(connectPromise).rejects.toMatchObject({
|
||||
requiredScope: 'files:write',
|
||||
errorDescription: 'write permission required',
|
||||
resourceMetadataUrl: new URL(`${MCP_URL}/.well-known/oauth-protected-resource`)
|
||||
});
|
||||
// No re-authorization was attempted.
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
expect(as.discoveryCalls).toHaveLength(0);
|
||||
expect(provider.redirectedTo).toHaveLength(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:stepup:get-stream-403', async (_args: TestArgs) => {
|
||||
// The GET listen-stream open path applies the same step-up handling.
|
||||
// We assert via 'throw' mode (parity with the POST path is the same private
|
||||
// helper) so the test observes the GET branch reaching the step-up gate.
|
||||
const provider = new RecordingOAuthClientProvider({
|
||||
tokens: { access_token: 't', token_type: 'Bearer' },
|
||||
clientInformation: { client_id: 'pre-registered-client' }
|
||||
});
|
||||
const seenMethods: string[] = [];
|
||||
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
seenMethods.push(init?.method ?? 'GET');
|
||||
return new Response(null, {
|
||||
status: 403,
|
||||
headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="listen"' }
|
||||
});
|
||||
};
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
|
||||
authProvider: provider,
|
||||
fetch: combinedFetch,
|
||||
onInsufficientScope: 'throw'
|
||||
});
|
||||
await transport.start();
|
||||
try {
|
||||
const resumePromise = transport.resumeStream('last-event-42');
|
||||
await expect(resumePromise).rejects.toBeInstanceOf(InsufficientScopeError);
|
||||
await expect(resumePromise).rejects.toMatchObject({ requiredScope: 'listen' });
|
||||
expect(seenMethods).toEqual(['GET']);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:as-metadata-discovery:priority-order', async (_args: TestArgs) => {
|
||||
const oauthMetadata: AuthorizationServerMetadata = {
|
||||
issuer: ISSUER,
|
||||
|
|
@ -519,7 +722,11 @@ verifies('client-auth:as-metadata-discovery:issuer-validation', async (_args: Te
|
|||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
|
||||
try {
|
||||
await expect(client.connect(transport)).rejects.toThrow(/issuer/i);
|
||||
const err: unknown = await client.connect(transport).catch((error: unknown) => error);
|
||||
expect(err).toBeInstanceOf(IssuerMismatchError);
|
||||
expect((err as IssuerMismatchError).kind).toBe('metadata');
|
||||
// Intentionally not an OAuthError — the auth() retry block must not swallow it.
|
||||
expect(err).not.toBeInstanceOf(OAuthError);
|
||||
|
||||
// The mismatched metadata is rejected before registering, redirecting the user, or requesting tokens.
|
||||
expect(as.registerCalls).toHaveLength(0);
|
||||
|
|
@ -531,6 +738,152 @@ verifies('client-auth:as-metadata-discovery:issuer-validation', async (_args: Te
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs the redirect leg of the OAuth flow against a mock AS configured with `asMetadata`, then
|
||||
* calls `transport.finishAuth(...)`. When `callback` is a string (or undefined) it is passed as
|
||||
* `finishAuth('granted-code', iss)`; when it is a `URLSearchParams` it is passed verbatim to the
|
||||
* overload. Returns the thrown error (or undefined on success) and the recorded token-endpoint
|
||||
* calls so the caller can assert whether the code went on the wire.
|
||||
*/
|
||||
async function runFinishAuthScenario(asMetadata: Partial<AuthorizationServerMetadata>, callback: string | undefined | URLSearchParams) {
|
||||
const as = createMockAuthorizationServer({ asMetadata, tokenResponses: [{ access_token: 'iss-flow-token', token_type: 'Bearer' }] });
|
||||
const provider = new RecordingOAuthClientProvider();
|
||||
const mcpHost = createAuthenticatedHost('iss-flow-token');
|
||||
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'iss-flow-token' });
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
try {
|
||||
// First connect → 401 → discovery → REDIRECT.
|
||||
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
|
||||
expect(provider.redirectedTo).toHaveLength(1);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await (callback instanceof URLSearchParams ? transport.finishAuth(callback) : transport.finishAuth('granted-code', callback));
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
return { thrown, tokenCalls: as.tokenCalls, provider };
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
}
|
||||
|
||||
verifies('client-auth:iss:match', async (_args: TestArgs) => {
|
||||
const { thrown, tokenCalls, provider } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, ISSUER);
|
||||
expect(thrown).toBeUndefined();
|
||||
expect(tokenCalls).toHaveLength(1);
|
||||
expect(defined(tokenCalls[0], 'token call').body.get('code')).toBe('granted-code');
|
||||
expect(provider.saved.tokens?.access_token).toBe('iss-flow-token');
|
||||
});
|
||||
|
||||
verifies('client-auth:iss:mismatch-reject', async (_args: TestArgs) => {
|
||||
const { thrown, tokenCalls } = await runFinishAuthScenario(
|
||||
{ authorization_response_iss_parameter_supported: true },
|
||||
'https://attacker.example.com'
|
||||
);
|
||||
expect(thrown).toBeInstanceOf(IssuerMismatchError);
|
||||
expect((thrown as IssuerMismatchError).kind).toBe('authorization_response');
|
||||
expect((thrown as IssuerMismatchError).expected).toBe(ISSUER);
|
||||
// The authorization code never reaches a token endpoint.
|
||||
expect(tokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client-auth:iss:supported-missing-reject', async (_args: TestArgs) => {
|
||||
const { thrown, tokenCalls } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, undefined);
|
||||
expect(thrown).toBeInstanceOf(IssuerMismatchError);
|
||||
expect((thrown as IssuerMismatchError).received).toBeUndefined();
|
||||
expect(tokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client-auth:iss:unadvertised-proceed', async (_args: TestArgs) => {
|
||||
// Row 4: not advertised + iss absent → the exchange proceeds. Also covers row 3 (not advertised
|
||||
// + iss present → still compared) by additionally asserting the same scenario rejects a wrong iss.
|
||||
const proceed = await runFinishAuthScenario({}, undefined);
|
||||
expect(proceed.thrown).toBeUndefined();
|
||||
expect(proceed.tokenCalls).toHaveLength(1);
|
||||
|
||||
const reject = await runFinishAuthScenario({}, 'https://attacker.example.com');
|
||||
expect(reject.thrown).toBeInstanceOf(IssuerMismatchError);
|
||||
expect(reject.tokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client-auth:iss:no-normalize', async (_args: TestArgs) => {
|
||||
// Each value is URL-equivalent to ISSUER under RFC 3986 §6.2.2-6.2.3 normalization, but
|
||||
// RFC 9207 mandates simple string comparison — every one MUST be rejected.
|
||||
for (const iss of [ISSUER.toUpperCase(), `${ISSUER}/`, `${ISSUER}:443`, ISSUER.replace('https', 'HTTPS')]) {
|
||||
expect(() => validateAuthorizationResponseIssuer({ iss, expectedIssuer: ISSUER, issParameterSupported: true })).toThrow(
|
||||
IssuerMismatchError
|
||||
);
|
||||
}
|
||||
|
||||
// And end-to-end through finishAuth(): a trailing-slash difference is a real reject.
|
||||
const { thrown, tokenCalls } = await runFinishAuthScenario({ authorization_response_iss_parameter_supported: true }, `${ISSUER}/`);
|
||||
expect(thrown).toBeInstanceOf(IssuerMismatchError);
|
||||
expect(tokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client-auth:iss:opt-out', async (_args: TestArgs) => {
|
||||
// skipIssuerMetadataValidation suppresses AU-02 (metadata echo): mismatched-issuer metadata is accepted.
|
||||
const as = createMockAuthorizationServer({ asMetadata: { issuer: 'https://misconfigured.example.com' } });
|
||||
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
|
||||
const provider = new RecordingOAuthClientProvider();
|
||||
await auth(provider, { serverUrl: MCP_URL, skipIssuerMetadataValidation: true, fetchFn });
|
||||
expect(provider.redirectedTo).toHaveLength(1);
|
||||
|
||||
// It does NOT suppress AU-01 (callback iss): a mismatched iss is still rejected before token exchange.
|
||||
await expect(
|
||||
auth(provider, {
|
||||
serverUrl: MCP_URL,
|
||||
authorizationCode: 'granted-code',
|
||||
iss: 'https://attacker.example.com',
|
||||
skipIssuerMetadataValidation: true,
|
||||
fetchFn
|
||||
})
|
||||
).rejects.toThrow(IssuerMismatchError);
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client-auth:finishauth:urlsearchparams-sanitizes', async (_args: TestArgs) => {
|
||||
const ATTACKER_TEXT = 'ATTACKER_CONTROLLED_DO_NOT_DISPLAY';
|
||||
const ATTACKER_URI = 'https://attacker.example.com/phish';
|
||||
|
||||
// Mismatched-iss callback that ALSO carries error/error_description/error_uri — a mix-up
|
||||
// attacker controls all of these. The overload must throw IssuerMismatchError before reading
|
||||
// them, so none of the attacker text appears on the thrown error.
|
||||
const mixed = await runFinishAuthScenario(
|
||||
{ authorization_response_iss_parameter_supported: true },
|
||||
new URLSearchParams({
|
||||
code: 'granted-code',
|
||||
state: 'state-123',
|
||||
iss: 'https://attacker.example.com',
|
||||
error: 'server_error',
|
||||
error_description: ATTACKER_TEXT,
|
||||
error_uri: ATTACKER_URI
|
||||
})
|
||||
);
|
||||
expect(mixed.thrown).toBeInstanceOf(IssuerMismatchError);
|
||||
const err = mixed.thrown as IssuerMismatchError;
|
||||
expect(err.kind).toBe('authorization_response');
|
||||
expect(err.message).not.toContain(ATTACKER_TEXT);
|
||||
expect(err.message).not.toContain(ATTACKER_URI);
|
||||
expect(JSON.stringify(err)).not.toContain(ATTACKER_TEXT);
|
||||
// The poisoned code never reached a token endpoint.
|
||||
expect(mixed.tokenCalls).toHaveLength(0);
|
||||
|
||||
// Happy path: matching iss → SDK extracts code and redeems it.
|
||||
const ok = await runFinishAuthScenario(
|
||||
{ authorization_response_iss_parameter_supported: true },
|
||||
new URLSearchParams({ code: 'granted-code', state: 'state-123', iss: ISSUER })
|
||||
);
|
||||
expect(ok.thrown).toBeUndefined();
|
||||
expect(ok.tokenCalls).toHaveLength(1);
|
||||
expect(defined(ok.tokenCalls[0], 'token call').body.get('code')).toBe('granted-code');
|
||||
expect(ok.provider.saved.tokens?.access_token).toBe('iss-flow-token');
|
||||
});
|
||||
|
||||
verifies('client-auth:bearer-header:every-request', async (_args: TestArgs) => {
|
||||
const validToken = 'bearer-test-token';
|
||||
const provider = new RecordingOAuthClientProvider({
|
||||
|
|
@ -706,9 +1059,11 @@ verifies('client-auth:invalid-client-clears-all', async (_args: TestArgs) => {
|
|||
expect(as.tokenCalls).toHaveLength(1);
|
||||
expect(defined(as.tokenCalls[0], 'token call').body.get('grant_type')).toBe('refresh_token');
|
||||
|
||||
// Everything is invalidated: tokens are gone and the stale client_id was discarded,
|
||||
// forcing a fresh dynamic registration on the retry.
|
||||
expect(provider.invalidatedCredentials).toContain('all');
|
||||
// Client + tokens are invalidated (NOT 'all', so discoveryState survives — SEP-2352):
|
||||
// tokens are gone and the stale client_id was discarded, forcing a fresh dynamic
|
||||
// registration on the retry.
|
||||
expect(provider.invalidatedCredentials).toContain('client');
|
||||
expect(provider.invalidatedCredentials).toContain('tokens');
|
||||
expect(provider.saved.tokens).toBeUndefined();
|
||||
expect(as.registerCalls).toHaveLength(1);
|
||||
expect(provider.saved.clientInformation?.client_id).toBe('registered-client-id');
|
||||
|
|
@ -921,8 +1276,10 @@ verifies('client-auth:prm-discovery:fallback-order', async (_args: TestArgs) =>
|
|||
|
||||
verifies('client-auth:prm-discovery:no-prm-fallback', async (_args: TestArgs) => {
|
||||
const VALID = 'legacy-fallback-token';
|
||||
// RFC 8414 §3.3: metadata fetched at the MCP origin must claim that origin as its issuer.
|
||||
const as = createMockAuthorizationServer({
|
||||
noPRMDiscovery: true,
|
||||
asMetadata: { issuer: new URL(MCP_URL).origin },
|
||||
tokenResponses: [{ access_token: VALID, token_type: 'Bearer' }]
|
||||
});
|
||||
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'legacy-fallback-client' } });
|
||||
|
|
@ -2038,3 +2395,456 @@ verifies('client-transport:sse:401-unauthorized-code', async (_args: TestArgs) =
|
|||
await authTransport.close();
|
||||
}
|
||||
});
|
||||
|
||||
// --- SEP-837 / SEP-2207 (DCR hygiene) -------------------------------------------------
|
||||
|
||||
verifies(['client-auth:dcr:app-type-heuristic', 'client-auth:dcr:grant-types-default'], async (_args: TestArgs) => {
|
||||
const as = createMockAuthorizationServer();
|
||||
// No application_type, no grant_types in clientMetadata; redirect_uri is loopback.
|
||||
const provider = new RecordingOAuthClientProvider();
|
||||
const mcpHost = createAuthenticatedHost('dcr-token');
|
||||
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'dcr-token' });
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
|
||||
try {
|
||||
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
|
||||
|
||||
expect(as.registerCalls).toHaveLength(1);
|
||||
const body = defined(as.registerCalls[0], 'registration call').body;
|
||||
// SEP-837: loopback redirect URI → 'native' by heuristic.
|
||||
expect(body.application_type).toBe('native');
|
||||
// SEP-2207: omitted grant_types → defaulted to include refresh_token.
|
||||
expect(body.grant_types).toEqual(['authorization_code', 'refresh_token']);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies(
|
||||
'client-auth:dcr:app-type-heuristic',
|
||||
async (_args: TestArgs) => {
|
||||
// Heuristic 'web' branch: drive registerClient() with a resolveClientMetadata() result so the
|
||||
// test can use a non-loopback redirect URI without going through auth()'s redirectUrl plumbing.
|
||||
const as = createMockAuthorizationServer();
|
||||
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
|
||||
|
||||
await registerClient(ISSUER, {
|
||||
clientMetadata: resolveClientMetadata({
|
||||
clientMetadata: { client_name: 'web-app', redirect_uris: ['https://app.example.com/callback'] },
|
||||
redirectUrl: 'https://app.example.com/callback'
|
||||
}),
|
||||
fetchFn
|
||||
});
|
||||
|
||||
expect(as.registerCalls).toHaveLength(1);
|
||||
expect(defined(as.registerCalls[0], 'registration call').body.application_type).toBe('web');
|
||||
},
|
||||
{ title: "non-loopback https redirect URI defaults to 'web'" }
|
||||
);
|
||||
|
||||
verifies('client-auth:dcr:app-type-override', async (_args: TestArgs) => {
|
||||
const as = createMockAuthorizationServer();
|
||||
// Loopback redirect URI but the consumer explicitly says 'web' (e.g. web app dev-served on localhost).
|
||||
const provider = new RecordingOAuthClientProvider({ clientMetadata: { application_type: 'web' } });
|
||||
const mcpHost = createAuthenticatedHost('dcr-token');
|
||||
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'dcr-token' });
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
|
||||
try {
|
||||
await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError);
|
||||
|
||||
expect(as.registerCalls).toHaveLength(1);
|
||||
const body = defined(as.registerCalls[0], 'registration call').body;
|
||||
expect(body.application_type).toBe('web'); // heuristic would have picked 'native'
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:dcr:grant-types-default', async (_args: TestArgs) => {
|
||||
// Consumer-set grant_types is never rewritten.
|
||||
const as = createMockAuthorizationServer();
|
||||
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
|
||||
|
||||
await registerClient(ISSUER, {
|
||||
clientMetadata: { client_name: 'm2m', redirect_uris: ['http://localhost:3000/cb'], grant_types: ['client_credentials'] },
|
||||
fetchFn
|
||||
});
|
||||
|
||||
expect(as.registerCalls).toHaveLength(1);
|
||||
expect(defined(as.registerCalls[0], 'registration call').body.grant_types).toEqual(['client_credentials']);
|
||||
});
|
||||
|
||||
verifies('client-auth:dcr:registration-rejected-error', async (_args: TestArgs) => {
|
||||
const as = createMockAuthorizationServer({
|
||||
registerErrorResponse: { status: 400, error: 'invalid_redirect_uri', error_description: 'loopback not permitted' }
|
||||
});
|
||||
const provider = new RecordingOAuthClientProvider();
|
||||
const mcpHost = createAuthenticatedHost('never');
|
||||
const combinedFetch = createCombinedFetch({ as, mcpHost, validToken: 'never' });
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
|
||||
|
||||
try {
|
||||
const err = await client.connect(transport).catch((error: unknown) => error);
|
||||
|
||||
// Propagates through auth() — not caught by the OAuthError retry path.
|
||||
expect(err).toBeInstanceOf(RegistrationRejectedError);
|
||||
expect(err).not.toBeInstanceOf(OAuthError);
|
||||
const rre = err as RegistrationRejectedError;
|
||||
expect(rre.status).toBe(400);
|
||||
expect(rre.body).toContain('invalid_redirect_uri');
|
||||
// Submitted metadata reflects what was POSTed (after defaults applied) so callers can adjust+retry.
|
||||
expect(rre.submittedMetadata.application_type).toBe('native');
|
||||
expect(rre.submittedMetadata.redirect_uris).toEqual(['http://localhost:3000/callback']);
|
||||
// Exactly one /register call — the auth() recovery path did not silently retry.
|
||||
expect(as.registerCalls).toHaveLength(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:token-endpoint:https-guard', async (_args: TestArgs) => {
|
||||
// AS metadata advertises a non-https, non-loopback token endpoint.
|
||||
const as = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://auth.example.com/token' } });
|
||||
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'https-guard-client' } });
|
||||
provider.saveCodeVerifier('verifier');
|
||||
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
|
||||
|
||||
// Exchange path through auth(): redeeming an authorization code is rejected before the request is sent.
|
||||
await expect(auth(provider, { serverUrl: MCP_URL, authorizationCode: 'code', fetchFn })).rejects.toThrow(InsecureTokenEndpointError);
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
|
||||
// Refresh path through auth(): a stored refresh_token + non-https token endpoint surfaces the
|
||||
// configuration error to the caller — it is NOT swallowed into a silent /authorize redirect.
|
||||
const refreshProvider = new RecordingOAuthClientProvider({
|
||||
clientInformation: { client_id: 'https-guard-client' },
|
||||
tokens: { access_token: 'old', token_type: 'Bearer', refresh_token: 'rt' }
|
||||
});
|
||||
await expect(auth(refreshProvider, { serverUrl: MCP_URL, fetchFn })).rejects.toThrow(InsecureTokenEndpointError);
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
expect(refreshProvider.redirectedTo).toHaveLength(0);
|
||||
|
||||
// And the lower-level helper rejects with the same dedicated class.
|
||||
await expect(
|
||||
refreshAuthorization(ISSUER, {
|
||||
metadata: {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: 'http://auth.example.com/token',
|
||||
response_types_supported: ['code']
|
||||
},
|
||||
clientInformation: { client_id: 'https-guard-client' },
|
||||
refreshToken: 'rt',
|
||||
fetchFn
|
||||
})
|
||||
).rejects.toThrow(InsecureTokenEndpointError);
|
||||
expect(as.tokenCalls).toHaveLength(0);
|
||||
|
||||
// Loopback exemption: the in-process mock AS itself uses an https issuer; cover the exemption
|
||||
// directly so a future tightening of the guard does not silently break local-dev / test setups.
|
||||
const loopbackAs = createMockAuthorizationServer({ asMetadata: { token_endpoint: 'http://127.0.0.1:9001/token' } });
|
||||
// Route the loopback token URL to the mock.
|
||||
const loopbackFetch = (url: URL | string, init?: RequestInit) => loopbackAs.handleRequest(new Request(url, init));
|
||||
await expect(
|
||||
refreshAuthorization(ISSUER, {
|
||||
metadata: {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: 'http://127.0.0.1:9001/token',
|
||||
response_types_supported: ['code']
|
||||
},
|
||||
clientInformation: { client_id: 'https-guard-client' },
|
||||
refreshToken: 'rt',
|
||||
fetchFn: loopbackFetch
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
verifies('client-auth:refresh:rotation-handling', async (_args: TestArgs) => {
|
||||
const clientInformation = { client_id: 'rotation-client', client_secret: 's' };
|
||||
const as = createMockAuthorizationServer({
|
||||
tokenResponses: [
|
||||
{ access_token: 'a1', token_type: 'Bearer' }, // no refresh_token issued
|
||||
{ access_token: 'a2', token_type: 'Bearer' }, // refresh: AS omits refresh_token → keep prior
|
||||
{ access_token: 'a3', token_type: 'Bearer', refresh_token: 'rt-new' } // refresh: rotated
|
||||
]
|
||||
});
|
||||
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
|
||||
|
||||
// No-assume-issuance: token response without refresh_token parses cleanly.
|
||||
const t1 = await exchangeAuthorization(ISSUER, {
|
||||
clientInformation,
|
||||
authorizationCode: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
fetchFn
|
||||
});
|
||||
expect(t1.refresh_token).toBeUndefined();
|
||||
|
||||
// Prior refresh_token preserved when AS omits a replacement.
|
||||
const t2 = await refreshAuthorization(ISSUER, { clientInformation, refreshToken: 'rt-old', fetchFn });
|
||||
expect(t2.refresh_token).toBe('rt-old');
|
||||
|
||||
// Rotated refresh_token adopted when AS returns one.
|
||||
const t3 = await refreshAuthorization(ISSUER, { clientInformation, refreshToken: 'rt-old', fetchFn });
|
||||
expect(t3.refresh_token).toBe('rt-new');
|
||||
});
|
||||
|
||||
verifies('client-auth:scope:offline-access-gate', async (_args: TestArgs) => {
|
||||
// AS advertises offline_access; provider explicitly sets grant_types including refresh_token,
|
||||
// so offline_access is appended to the requested scope on authorize.
|
||||
const asWith = createMockAuthorizationServer({ asMetadata: { scopes_supported: ['openid', 'offline_access'] } });
|
||||
const providerWith = new RecordingOAuthClientProvider({
|
||||
clientMetadata: { grant_types: ['authorization_code', 'refresh_token'] }
|
||||
});
|
||||
const fetchWith = createCombinedFetch({ as: asWith, mcpHost: createAuthenticatedHost('never'), validToken: 'never' });
|
||||
|
||||
await auth(providerWith, { serverUrl: MCP_URL, fetchFn: fetchWith });
|
||||
|
||||
const redirectWith = defined(providerWith.redirectedTo[0], 'authorization redirect URL');
|
||||
expect(redirectWith.searchParams.get('scope')?.split(' ')).toContain('offline_access');
|
||||
|
||||
// AS does NOT advertise offline_access → never appended.
|
||||
const asWithout = createMockAuthorizationServer({ asMetadata: { scopes_supported: ['openid'] } });
|
||||
const providerWithout = new RecordingOAuthClientProvider();
|
||||
const fetchWithout = createCombinedFetch({ as: asWithout, mcpHost: createAuthenticatedHost('never'), validToken: 'never' });
|
||||
|
||||
await auth(providerWithout, { serverUrl: MCP_URL, fetchFn: fetchWithout });
|
||||
|
||||
const redirectWithout = defined(providerWithout.redirectedTo[0], 'authorization redirect URL');
|
||||
expect((redirectWithout.searchParams.get('scope') ?? '').split(' ')).not.toContain('offline_access');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SEP-2352 — per-authorization-server credential isolation. Stored tokens and
|
||||
// client credentials carry an SDK-stamped `issuer`; a value stamped for a
|
||||
// different AS reads back as undefined, so it is never reused on the wire.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A protected resource whose `authorization_servers` PRM entry can be swapped
|
||||
* between calls. Each issuer hosts its own DCR endpoint that mints a distinct
|
||||
* `client_id`, so reuse of an old-AS `client_id` is observable on the wire.
|
||||
*/
|
||||
function createMigratingAuthorizationServer() {
|
||||
const issuers = { one: 'https://as-one.example.com', two: 'https://as-two.example.com' } as const;
|
||||
let active: keyof typeof issuers = 'one';
|
||||
const registerCalls: Array<{ issuer: string }> = [];
|
||||
const clientIdsSeen: Array<{ issuer: string; clientId: string | null }> = [];
|
||||
const tokenCalls: Array<{ issuer: string; body: URLSearchParams }> = [];
|
||||
|
||||
const asMetadata = (issuer: string): AuthorizationServerMetadata => ({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
registration_endpoint: `${issuer}/register`,
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
client_id_metadata_document_supported: true,
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials']
|
||||
});
|
||||
|
||||
const fetchFn = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const u = typeof url === 'string' ? new URL(url) : url;
|
||||
if (u.pathname.includes('/.well-known/oauth-protected-resource')) {
|
||||
return Response.json({ resource: RESOURCE, authorization_servers: [issuers[active]] });
|
||||
}
|
||||
if (u.pathname.includes('/.well-known/oauth-authorization-server') || u.pathname.includes('/.well-known/openid-configuration')) {
|
||||
return Response.json(asMetadata(u.origin));
|
||||
}
|
||||
if (u.pathname === '/register' && init?.method === 'POST') {
|
||||
const body = z.record(z.string(), z.unknown()).parse(JSON.parse(String(init.body)));
|
||||
registerCalls.push({ issuer: u.origin });
|
||||
return Response.json({ ...body, client_id: `cid-at-${u.host}`, client_secret: `secret-at-${u.host}` }, { status: 201 });
|
||||
}
|
||||
if (u.pathname === '/authorize') {
|
||||
clientIdsSeen.push({ issuer: u.origin, clientId: u.searchParams.get('client_id') });
|
||||
return new Response('Authorize', { status: 200 });
|
||||
}
|
||||
if (u.pathname === '/token' && init?.method === 'POST') {
|
||||
const body = new URLSearchParams(String(init.body));
|
||||
clientIdsSeen.push({ issuer: u.origin, clientId: body.get('client_id') });
|
||||
tokenCalls.push({ issuer: u.origin, body });
|
||||
return Response.json({ access_token: `tok-${u.host}`, token_type: 'Bearer' });
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
};
|
||||
|
||||
return {
|
||||
issuers,
|
||||
registerCalls,
|
||||
clientIdsSeen,
|
||||
tokenCalls,
|
||||
fetchFn,
|
||||
switchTo(which: keyof typeof issuers) {
|
||||
active = which;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-slot blob provider — round-trips the SDK-stamped values verbatim. */
|
||||
class StampedBlobProvider implements OAuthClientProvider {
|
||||
redirectedTo: URL[] = [];
|
||||
info?: StoredOAuthClientInformation;
|
||||
storedTokens?: StoredOAuthTokens;
|
||||
discovery?: OAuthDiscoveryState;
|
||||
private _verifier?: string;
|
||||
|
||||
constructor(public readonly clientMetadataUrl?: string) {}
|
||||
|
||||
get redirectUrl() {
|
||||
return 'http://localhost:3000/callback';
|
||||
}
|
||||
get clientMetadata() {
|
||||
return { client_name: 'Test Client', redirect_uris: [this.redirectUrl] };
|
||||
}
|
||||
clientInformation() {
|
||||
return this.info;
|
||||
}
|
||||
saveClientInformation(i: OAuthClientInformationMixed) {
|
||||
this.info = i;
|
||||
}
|
||||
tokens() {
|
||||
return this.storedTokens;
|
||||
}
|
||||
saveTokens(t: OAuthTokens) {
|
||||
this.storedTokens = t;
|
||||
}
|
||||
redirectToAuthorization(u: URL) {
|
||||
this.redirectedTo.push(u);
|
||||
}
|
||||
saveCodeVerifier(v: string) {
|
||||
this._verifier = v;
|
||||
}
|
||||
codeVerifier() {
|
||||
if (!this._verifier) throw new Error('no verifier');
|
||||
return this._verifier;
|
||||
}
|
||||
discoveryState() {
|
||||
return this.discovery;
|
||||
}
|
||||
saveDiscoveryState(s: OAuthDiscoveryState) {
|
||||
this.discovery = s;
|
||||
}
|
||||
invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery') {
|
||||
if (scope === 'all' || scope === 'discovery') this.discovery = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
verifies('client-auth:as-migration:reregister', async (_args: TestArgs) => {
|
||||
const server = createMigratingAuthorizationServer();
|
||||
const provider = new StampedBlobProvider();
|
||||
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
expect(server.registerCalls).toEqual([{ issuer: server.issuers.one }]);
|
||||
expect(provider.info?.issuer).toBe(server.issuers.one);
|
||||
|
||||
// PRM now lists AS-two. Drop cached discovery (as a host would on a fresh 401) so
|
||||
// re-discovery picks up the new AS.
|
||||
server.switchTo('two');
|
||||
provider.invalidateCredentials('discovery');
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
|
||||
// Stamp mismatch → undefined → re-registers at AS-two, redirect carries the fresh client_id.
|
||||
expect(server.registerCalls).toEqual([{ issuer: server.issuers.one }, { issuer: server.issuers.two }]);
|
||||
const redirect = defined(provider.redirectedTo.at(-1), 'second redirect');
|
||||
expect(redirect.origin).toBe(server.issuers.two);
|
||||
expect(redirect.searchParams.get('client_id')).toBe('cid-at-as-two.example.com');
|
||||
});
|
||||
|
||||
verifies('client-auth:as-migration:no-cred-reuse', async (_args: TestArgs) => {
|
||||
const server = createMigratingAuthorizationServer();
|
||||
const provider = new StampedBlobProvider();
|
||||
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
expect(provider.info?.client_id).toBe('cid-at-as-one.example.com');
|
||||
|
||||
server.switchTo('two');
|
||||
provider.invalidateCredentials('discovery');
|
||||
await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn });
|
||||
|
||||
// Wire MUST: AS-two never received AS-one's client_id at any endpoint.
|
||||
for (const seen of server.clientIdsSeen.filter(c => c.issuer === server.issuers.two)) {
|
||||
expect(seen.clientId).not.toBe('cid-at-as-one.example.com');
|
||||
}
|
||||
expect(provider.info?.client_id).toBe('cid-at-as-two.example.com');
|
||||
expect(provider.info?.issuer).toBe(server.issuers.two);
|
||||
});
|
||||
|
||||
verifies('client-auth:as-migration:no-token-reuse', async (_args: TestArgs) => {
|
||||
const server = createMigratingAuthorizationServer();
|
||||
const provider = new StampedBlobProvider();
|
||||
|
||||
// Completed flow against AS-one — provider holds an AS-one-stamped refresh_token.
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn, authorizationCode: 'code-a' })).toBe('AUTHORIZED');
|
||||
provider.storedTokens = { ...defined(provider.storedTokens, 'tokens'), refresh_token: 'rt-one' };
|
||||
expect(provider.storedTokens.issuer).toBe(server.issuers.one);
|
||||
|
||||
server.switchTo('two');
|
||||
provider.invalidateCredentials('discovery');
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
|
||||
// Wire MUST: AS-two's /token never received a refresh_token grant or rt-one.
|
||||
for (const { body } of server.tokenCalls.filter(c => c.issuer === server.issuers.two)) {
|
||||
expect(body.get('grant_type')).not.toBe('refresh_token');
|
||||
expect(body.get('refresh_token')).not.toBe('rt-one');
|
||||
}
|
||||
});
|
||||
|
||||
verifies('client-auth:as-migration:cimd-portable', async (_args: TestArgs) => {
|
||||
const cimdUrl = 'https://client.example.com/.well-known/client-metadata.json';
|
||||
const server = createMigratingAuthorizationServer();
|
||||
const provider = new StampedBlobProvider(cimdUrl);
|
||||
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
expect(server.registerCalls).toHaveLength(0);
|
||||
expect(provider.info?.client_id).toBe(cimdUrl);
|
||||
|
||||
server.switchTo('two');
|
||||
provider.invalidateCredentials('discovery');
|
||||
expect(await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('REDIRECT');
|
||||
|
||||
// No DCR; the same URL-based client_id is presented to the new AS (re-stamped).
|
||||
expect(server.registerCalls).toHaveLength(0);
|
||||
const redirect = defined(provider.redirectedTo.at(-1), 'second redirect');
|
||||
expect(redirect.origin).toBe(server.issuers.two);
|
||||
expect(redirect.searchParams.get('client_id')).toBe(cimdUrl);
|
||||
expect(provider.info?.issuer).toBe(server.issuers.two);
|
||||
});
|
||||
|
||||
verifies('client-auth:as-migration:m2m-expected-issuer', async (_args: TestArgs) => {
|
||||
const server = createMigratingAuthorizationServer();
|
||||
|
||||
// expectedIssuer = AS-one, but PRM points to AS-two → stamp mismatch → undefined →
|
||||
// no saveClientInformation → AuthorizationServerMismatchError before any token request.
|
||||
server.switchTo('two');
|
||||
const provider = new ClientCredentialsProvider({
|
||||
clientId: 'static-cid',
|
||||
clientSecret: 'static-secret',
|
||||
expectedIssuer: server.issuers.one
|
||||
});
|
||||
await expect(auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).rejects.toThrow(AuthorizationServerMismatchError);
|
||||
expect(server.tokenCalls.filter(c => c.issuer === server.issuers.two)).toHaveLength(0);
|
||||
expect(server.clientIdsSeen.filter(c => c.issuer === server.issuers.two)).toHaveLength(0);
|
||||
|
||||
// Matching expectedIssuer proceeds and stamps the saved tokens.
|
||||
server.switchTo('one');
|
||||
const ok = new ClientCredentialsProvider({
|
||||
clientId: 'static-cid',
|
||||
clientSecret: 'static-secret',
|
||||
expectedIssuer: server.issuers.one
|
||||
});
|
||||
expect(await auth(ok, { serverUrl: MCP_URL, fetchFn: server.fetchFn })).toBe('AUTHORIZED');
|
||||
expect(ok.tokens()?.issuer).toBe(server.issuers.one);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) =
|
|||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const inFlightCall = client.callTool({ name: 'emit-log', arguments: {} });
|
||||
// On a 2026-era request the spec says an absent `_meta.logLevel` envelope key means the server MUST NOT
|
||||
// send notifications/message — so the entryModern arm needs the key set explicitly for the log to be
|
||||
// emitted. Legacy-era arms ignore the key (the session-scoped level applies; absent → no filter).
|
||||
const inFlightCall = client.callTool({ name: 'emit-log', arguments: {}, _meta: { 'io.modelcontextprotocol/logLevel': 'debug' } });
|
||||
try {
|
||||
// The handler is parked on the gate, so the tools/call request is still in flight when the log arrives.
|
||||
await vi.waitFor(() => expect(logs).toHaveLength(1));
|
||||
|
|
|
|||
337
test/e2e/scenarios/hosting-entry-auth.test.ts
Normal file
337
test/e2e/scenarios/hosting-entry-auth.test.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/**
|
||||
* Bearer auth composed with the dual-era HTTP entry (`createMcpHandler`), and
|
||||
* per-request HTTP context exposure on it. These are the entry-side siblings of
|
||||
* `hosting:auth:authinfo-propagates` / `hosting:auth:missing-401` /
|
||||
* `hosting:context:web-request-headers`, whose bodies hand-host an Express or
|
||||
* per-session stack and so never reach `createMcpHandler` when given an entry
|
||||
* arm.
|
||||
*
|
||||
* The SDK does not enforce endpoint authentication on either era — bearer auth
|
||||
* is deployer-composed middleware in front of whichever handler is mounted.
|
||||
* The composition under test here is the documented one: a user-shaped gate
|
||||
* verifies the Authorization header and, on success, hands the verified
|
||||
* `AuthInfo` to `handler.fetch(request, { authInfo })`. The entry never derives
|
||||
* `authInfo` from request headers; it is strictly pass-through to the factory's
|
||||
* per-request context and to handler `ctx.http.authInfo`. Each cell hosts the
|
||||
* composition itself behind an in-process fetch (the wire() entry arm has no
|
||||
* hook for the gate or for client-transport requestInit), and the matrix arm
|
||||
* selects which leg of the entry serves the authenticated traffic
|
||||
* (`entryStateless` → a plain client through the stateless legacy fallback;
|
||||
* `entryModern` → a 2026-07-28-pinned client through the modern-only strict
|
||||
* path).
|
||||
*/
|
||||
import { Client, InsufficientScopeError, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { AuthInfo, McpRequestContext } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { verifies } from '../helpers/verifies';
|
||||
import type { TestArgs } from '../types';
|
||||
|
||||
const MODERN = '2026-07-28';
|
||||
const VALID_TOKEN = 'e2e-entry-access-token';
|
||||
|
||||
/** What the user's verifier derives from the Authorization header (a fresh object per request, so the assertion checks delivery, not identity). */
|
||||
function verifyBearer(header: string | null): AuthInfo | undefined {
|
||||
if (header !== `Bearer ${VALID_TOKEN}`) return undefined;
|
||||
return {
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'e2e-entry-caller',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
extra: { userId: 'user-42' }
|
||||
};
|
||||
}
|
||||
|
||||
/** The 401 a bearer gate answers a missing/invalid token with (mirrors the spec's WWW-Authenticate discovery shape). */
|
||||
function unauthorized(): Response {
|
||||
return Response.json(
|
||||
{ error: 'invalid_token' },
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'www-authenticate':
|
||||
'Bearer error="invalid_token", resource_metadata="http://in-process/.well-known/oauth-protected-resource"'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
verifies('typescript:hosting:entry:auth:missing-401', async ({ transport }: TestArgs) => {
|
||||
let factoryCalls = 0;
|
||||
const factory = (_ctx?: McpRequestContext): McpServer => {
|
||||
factoryCalls++;
|
||||
const server = new McpServer({ name: 'e2e-entry-auth', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
server.registerTool('whoami', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'reached' }] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
|
||||
await using _ = { [Symbol.asyncDispose]: () => handler.close() };
|
||||
|
||||
// The documented bearer-gate composition in front of the entry.
|
||||
const gatedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(url, init);
|
||||
const authInfo = verifyBearer(request.headers.get('authorization'));
|
||||
if (authInfo === undefined) return unauthorized();
|
||||
return handler.fetch(request, { authInfo });
|
||||
};
|
||||
|
||||
// 1. Raw probe without an Authorization header: the gate answers 401 with
|
||||
// the WWW-Authenticate challenge, and the entry is never reached.
|
||||
const probe = await gatedFetch('http://in-process/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })
|
||||
});
|
||||
expect(probe.status).toBe(401);
|
||||
const wwwAuthenticate = probe.headers.get('www-authenticate');
|
||||
expect(wwwAuthenticate).toContain('Bearer');
|
||||
expect(wwwAuthenticate).toContain('resource_metadata');
|
||||
expect(factoryCalls).toBe(0);
|
||||
|
||||
// 2. The plain SDK client (no Authorization on its requestInit) cannot
|
||||
// connect through the gate on either leg, and the entry is still never
|
||||
// reached. The exact connect-time error surface (401 wrapped by the
|
||||
// legacy POST or by the modern discover negotiation) is a client-auth
|
||||
// concern; this cell pins only that the gate composes — connect rejects
|
||||
// and no factory call runs.
|
||||
const plainClient = new Client({ name: 'plain-client', version: '1.0.0' });
|
||||
if (transport === 'entryModern') plainClient.setVersionNegotiation({ mode: { pin: MODERN } });
|
||||
try {
|
||||
await expect(
|
||||
plainClient.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: gatedFetch }))
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await plainClient.close().catch(() => {});
|
||||
}
|
||||
expect(factoryCalls).toBe(0);
|
||||
});
|
||||
|
||||
verifies('typescript:hosting:entry:auth:authinfo-propagates', async ({ transport }: TestArgs) => {
|
||||
// Recorders live outside the per-request factory.
|
||||
const seenByFactory: Array<AuthInfo | undefined> = [];
|
||||
const seenByTool: Array<AuthInfo | undefined> = [];
|
||||
|
||||
const factory = (ctx?: McpRequestContext): McpServer => {
|
||||
seenByFactory.push(ctx?.authInfo);
|
||||
const server = new McpServer({ name: 'e2e-entry-auth', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
server.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Reports the authenticated caller derived from ctx.http.authInfo.', inputSchema: z.object({}) },
|
||||
(_args, handlerCtx) => {
|
||||
seenByTool.push(handlerCtx.http?.authInfo);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: handlerCtx.http?.authInfo
|
||||
? `${handlerCtx.http.authInfo.clientId} [${handlerCtx.http.authInfo.scopes.join(' ')}] (${ctx?.era ?? 'unknown'})`
|
||||
: 'no-auth-info'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
return server;
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
|
||||
const gatedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(url, init);
|
||||
const authInfo = verifyBearer(request.headers.get('authorization'));
|
||||
if (authInfo === undefined) return unauthorized();
|
||||
return handler.fetch(request, { authInfo });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'auth-client', version: '1.0.0' });
|
||||
if (transport === 'entryModern') client.setVersionNegotiation({ mode: { pin: MODERN } });
|
||||
await using _ = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await client.close().catch(() => {});
|
||||
await handler.close();
|
||||
}
|
||||
};
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: gatedFetch,
|
||||
requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }
|
||||
})
|
||||
);
|
||||
if (transport === 'entryModern') expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
|
||||
|
||||
const result = await client.callTool({ name: 'whoami', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
const era = transport === 'entryStateless' ? 'legacy' : 'modern';
|
||||
expect(result.content).toEqual([{ type: 'text', text: `e2e-entry-caller [mcp:tools:read mcp:tools:call] (${era})` }]);
|
||||
|
||||
// The verified AuthInfo handed to handler.fetch(request, { authInfo })
|
||||
// reached the tool handler's ctx.http.authInfo unchanged — not dropped, not
|
||||
// replaced by a placeholder, not derived from a header.
|
||||
expect(seenByTool).toHaveLength(1);
|
||||
expect(seenByTool[0]).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'e2e-entry-caller',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
extra: { userId: 'user-42' }
|
||||
});
|
||||
|
||||
// ...and the same AuthInfo was on the factory's per-request context for
|
||||
// every instance the entry built (negotiation + the tools/call), so a
|
||||
// factory keying surface off authInfo sees it on the leg under test.
|
||||
expect(seenByFactory.length).toBeGreaterThan(0);
|
||||
for (const seen of seenByFactory) {
|
||||
expect(seen).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'e2e-entry-caller',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
extra: { userId: 'user-42' }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
verifies('typescript:hosting:entry:ctx-http-req-headers', async ({ transport }: TestArgs) => {
|
||||
const PROBE_HEADER = 'x-e2e-probe';
|
||||
const PROBE_VALUE = 'probe-7d1f';
|
||||
const seenByTool: Array<{ isFetchHeaders: boolean; probe: string | null }> = [];
|
||||
|
||||
const factory = (_ctx?: McpRequestContext): McpServer => {
|
||||
const server = new McpServer({ name: 'e2e-entry-ctx', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
server.registerTool('read-probe-header', { inputSchema: z.object({}) }, (_args, ctx) => {
|
||||
const headers = ctx.http?.req?.headers;
|
||||
seenByTool.push({
|
||||
isFetchHeaders: headers instanceof Headers,
|
||||
probe: headers instanceof Headers ? headers.get(PROBE_HEADER) : null
|
||||
});
|
||||
return { content: [{ type: 'text', text: headers?.get(PROBE_HEADER) ?? '<missing>' }] };
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
|
||||
const client = new Client({ name: 'ctx-client', version: '1.0.0' });
|
||||
if (transport === 'entryModern') client.setVersionNegotiation({ mode: { pin: MODERN } });
|
||||
await using _ = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await client.close().catch(() => {});
|
||||
await handler.close();
|
||||
}
|
||||
};
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: (url, init) => handler.fetch(new Request(url, init)),
|
||||
requestInit: { headers: { [PROBE_HEADER]: PROBE_VALUE } }
|
||||
})
|
||||
);
|
||||
|
||||
const result = await client.callTool({ name: 'read-probe-header', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: PROBE_VALUE }]);
|
||||
// The custom header set on the client transport is readable as Fetch
|
||||
// Headers inside the handler on the leg the matrix arm selected.
|
||||
expect(seenByTool).toEqual([{ isFetchHeaders: true, probe: PROBE_VALUE }]);
|
||||
});
|
||||
|
||||
verifies('typescript:hosting:entry:auth:insufficient-scope-403', async ({ transport }: TestArgs) => {
|
||||
// Per-operation scope requirements derived from the body's tool name. On the
|
||||
// modern leg the gate reads the SEP-2243 standard `Mcp-Method` / `Mcp-Name`
|
||||
// headers (the entry's documented per-operation routing surface); the legacy
|
||||
// leg has no such header so the gate falls back to one required scope.
|
||||
const REQUIRED_BY_TOOL: Record<string, string> = { 'write-file': 'files:write' };
|
||||
const TOKEN_SCOPES: Record<string, string[]> = {
|
||||
'read-only-token': ['files:read'],
|
||||
'read-write-token': ['files:read', 'files:write']
|
||||
};
|
||||
|
||||
let factoryCalls = 0;
|
||||
const factory = (ctx?: McpRequestContext): McpServer => {
|
||||
factoryCalls++;
|
||||
const server = new McpServer({ name: 'e2e-entry-scoped', version: '1.0.0' }, { capabilities: { tools: {} } });
|
||||
server.registerTool('list-files', { inputSchema: z.object({}) }, () => ({
|
||||
content: [{ type: 'text', text: `listed by ${ctx?.authInfo?.clientId}` }]
|
||||
}));
|
||||
server.registerTool('write-file', { inputSchema: z.object({}) }, () => ({
|
||||
content: [{ type: 'text', text: `written by ${ctx?.authInfo?.clientId}` }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(factory, { legacy: transport === 'entryStateless' ? 'stateless' : 'reject' });
|
||||
await using _ = { [Symbol.asyncDispose]: () => handler.close() };
|
||||
|
||||
const insufficientScope = (required: string): Response =>
|
||||
Response.json(
|
||||
{ error: 'insufficient_scope' },
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
'www-authenticate': `Bearer error="insufficient_scope", scope="${required}", error_description="${required} required for this operation"`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const gatedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(url, init);
|
||||
const auth = request.headers.get('authorization');
|
||||
const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined;
|
||||
if (!token) return unauthorized();
|
||||
const scopes = TOKEN_SCOPES[token];
|
||||
if (scopes === undefined) return unauthorized();
|
||||
const mcpName = request.headers.get('mcp-name') ?? undefined;
|
||||
const required: string =
|
||||
request.headers.get('mcp-method') === 'tools/call' && mcpName ? (REQUIRED_BY_TOOL[mcpName] ?? 'files:read') : 'files:read';
|
||||
if (!scopes.includes(required)) return insufficientScope(required);
|
||||
return handler.fetch(request, { authInfo: { token, clientId: 'e2e-scoped-caller', scopes } });
|
||||
};
|
||||
|
||||
// 1. With the read-only token: list-files reaches the entry; write-file
|
||||
// (modern leg) is rejected at the gate with 403 + insufficient_scope and
|
||||
// the entry is never reached for that request.
|
||||
const before = factoryCalls;
|
||||
const readClient = new Client({ name: 'scoped-client', version: '1.0.0' });
|
||||
if (transport === 'entryModern') readClient.setVersionNegotiation({ mode: { pin: MODERN } });
|
||||
await readClient.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: gatedFetch,
|
||||
requestInit: { headers: { Authorization: 'Bearer read-only-token' } },
|
||||
onInsufficientScope: 'throw'
|
||||
})
|
||||
);
|
||||
const listed = await readClient.callTool({ name: 'list-files', arguments: {} });
|
||||
expect(listed.content).toEqual([{ type: 'text', text: 'listed by e2e-scoped-caller' }]);
|
||||
const reachedAfterList = factoryCalls;
|
||||
expect(reachedAfterList).toBeGreaterThan(before);
|
||||
|
||||
if (transport === 'entryModern') {
|
||||
const writePromise = readClient.callTool({ name: 'write-file', arguments: {} });
|
||||
await expect(writePromise).rejects.toBeInstanceOf(InsufficientScopeError);
|
||||
await expect(writePromise).rejects.toMatchObject({ requiredScope: 'files:write' });
|
||||
// The 403 came from the gate; no factory call ran for that POST.
|
||||
expect(factoryCalls).toBe(reachedAfterList);
|
||||
} else {
|
||||
// Legacy leg: no Mcp-Name header → the gate's per-operation derivation
|
||||
// is not available, so write-file passes the gate (single required scope
|
||||
// fallback). The cell pins that the gate composes correctly with the
|
||||
// legacy serving path; per-operation enforcement on legacy is host
|
||||
// responsibility (e.g., by parsing the body).
|
||||
const written = await readClient.callTool({ name: 'write-file', arguments: {} });
|
||||
expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]);
|
||||
}
|
||||
await readClient.close().catch(() => {});
|
||||
|
||||
// 2. With the read-write token: write-file reaches the entry on both legs.
|
||||
const rwClient = new Client({ name: 'scoped-client', version: '1.0.0' });
|
||||
if (transport === 'entryModern') rwClient.setVersionNegotiation({ mode: { pin: MODERN } });
|
||||
await rwClient.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: gatedFetch,
|
||||
requestInit: { headers: { Authorization: 'Bearer read-write-token' } }
|
||||
})
|
||||
);
|
||||
const written = await rwClient.callTool({ name: 'write-file', arguments: {} });
|
||||
expect(written.content).toEqual([{ type: 'text', text: 'written by e2e-scoped-caller' }]);
|
||||
await rwClient.close().catch(() => {});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue