From d3ba7512db2fc9fb2c4c44bff164f8d0adbfd604 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 26 May 2026 16:12:09 +0000 Subject: [PATCH 1/6] feat: add MockServer abstraction and ScenarioContext MockServer encapsulates the lifecycle scaffold a client-conformance scenario presents to the client-under-test: - createServerStateful: 2025-x lifecycle. SDK Server + StreamableHTTPServerTransport (sessionless mode); the SDK handles the initialize handshake. - createServerStateless: 2026-x lifecycle (SEP-2575). Raw express app that validates _meta + MCP-Protocol-Version on every request, serves server/discover, routes other methods to the supplied handlers. createServerFor(specVersion) picks the implementation. ScenarioContext bundles specVersion and a bound createServer() for the runner to hand to each scenario. This is the client-conformance mirror of src/connection (PR #318). Nothing uses it yet; wiring follows in the next commit. --- src/mock-server/index.ts | 76 ++++++++++++++ src/mock-server/mock-server.test.ts | 148 ++++++++++++++++++++++++++++ src/mock-server/select.ts | 26 +++++ src/mock-server/stateful.ts | 111 +++++++++++++++++++++ src/mock-server/stateless.ts | 107 ++++++++++++++++++++ src/mock-server/testing.ts | 18 ++++ 6 files changed, 486 insertions(+) create mode 100644 src/mock-server/index.ts create mode 100644 src/mock-server/mock-server.test.ts create mode 100644 src/mock-server/select.ts create mode 100644 src/mock-server/stateful.ts create mode 100644 src/mock-server/stateless.ts create mode 100644 src/mock-server/testing.ts diff --git a/src/mock-server/index.ts b/src/mock-server/index.ts new file mode 100644 index 0000000..cdbbec1 --- /dev/null +++ b/src/mock-server/index.ts @@ -0,0 +1,76 @@ +/** + * Version-aware mock-server abstraction for client-conformance scenarios. + * + * A `MockServer` is the HTTP server a client-under-test connects to. The + * lifecycle scaffold (initialize handshake vs per-request `_meta` validation) + * is supplied by the runner based on `--spec-version`; the scenario only + * provides per-method handlers and asserts on the recorded requests. + * + * This is the client-conformance mirror of `Connection` in `../connection`. + */ + +import type express from 'express'; +import type { SpecVersion } from '../types'; +import type { JSONRPCRequest } from '../spec-types/2025-11-25'; + +/** + * Per-method response handlers. Called with the request `params` object; + * return value becomes the JSON-RPC `result`. Throw to produce an error + * response. + */ +export type RequestHandlers = Record< + string, + ( + params: Record, + request: JSONRPCRequest + ) => unknown | Promise +>; + +export interface MockServerOptions { + /** + * Capabilities advertised in InitializeResult / server/discover. Defaults to + * `{ tools: {} }`. + */ + capabilities?: Record; + /** + * Hook to add routes/middleware to the underlying express app before the + * `/mcp` route is registered. Auth scenarios use this for the PRM endpoint + * and bearer-auth middleware. + */ + configure?: (app: express.Application) => void; +} + +export interface MockServer { + /** Full URL of the `/mcp` endpoint. */ + url: string; + /** Base URL (no `/mcp` suffix), for scenarios that serve sibling routes. */ + baseUrl: string; + /** + * Every JSON-RPC request the client sent, in arrival order, excluding the + * lifecycle preamble (`initialize` / `notifications/initialized` under the + * stateful impl; nothing is excluded under stateless since there is none). + */ + readonly recorded: JSONRPCRequest[]; + close(): Promise; +} + +/** + * Per-run context handed to `Scenario.start()`. The runner constructs this + * from the resolved `--spec-version`. + */ +export interface ScenarioContext { + specVersion: SpecVersion; + /** + * Create a version-appropriate mock server. Scenarios that test the + * lifecycle itself (initialize, SSE-retry) bypass this and build a raw + * `http.createServer`. + */ + createServer( + handlers: RequestHandlers, + opts?: MockServerOptions + ): Promise; +} + +export { createServerStateful } from './stateful'; +export { createServerStateless } from './stateless'; +export { createServerFor } from './select'; diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts new file mode 100644 index 0000000..63274d2 --- /dev/null +++ b/src/mock-server/mock-server.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { createServerFor } from './select'; +import { createServerStateful } from './stateful'; +import { createServerStateless } from './stateless'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; + +describe('createServerFor', () => { + it('returns stateful for dated 2025-x versions', () => { + expect(createServerFor('2025-06-18')).toBe(createServerStateful); + expect(createServerFor('2025-11-25')).toBe(createServerStateful); + }); + it('returns stateless for the draft version', () => { + expect(createServerFor(DRAFT_PROTOCOL_VERSION)).toBe(createServerStateless); + }); +}); + +describe('createServerStateless', () => { + const meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 't', version: '1' }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + + async function post(url: string, body: object, headers: object = {}) { + const r = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body) + }); + return { status: r.status, body: await r.json() }; + } + + it('rejects requests missing the version header', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post(srv.url, { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }); + expect(status).toBe(400); + expect(body.error.code).toBe(-32001); + } finally { + await srv.close(); + } + }); + + it('rejects requests missing required _meta keys', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post( + srv.url, + { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(400); + expect(body.error.code).toBe(-32602); + } finally { + await srv.close(); + } + }); + + it('serves server/discover', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'server/discover', + params: { _meta: meta } + }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(200); + expect(body.result.supportedVersions).toEqual([DRAFT_PROTOCOL_VERSION]); + expect(body.result.serverInfo.name).toBe('conformance-mock-server'); + } finally { + await srv.close(); + } + }); + + it('routes to handlers and records requests', async () => { + const srv = await createServerStateless({ + 'tools/list': () => ({ tools: [{ name: 'x' }] }) + }); + try { + const { body } = await post( + srv.url, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: meta } + }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(body.result.tools[0].name).toBe('x'); + expect(srv.recorded).toHaveLength(1); + expect(srv.recorded[0].method).toBe('tools/list'); + } finally { + await srv.close(); + } + }); + + it('returns -32601 for unknown methods', async () => { + const srv = await createServerStateless({}); + try { + const { status, body } = await post( + srv.url, + { jsonrpc: '2.0', id: 1, method: 'nope', params: { _meta: meta } }, + { 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + ); + expect(status).toBe(404); + expect(body.error.code).toBe(-32601); + } finally { + await srv.close(); + } + }); +}); + +describe('createServerStateful', () => { + it('accepts initialize and routes to handlers, recording non-preamble', async () => { + const srv = await createServerStateful({ + 'tools/list': () => ({ tools: [] }) + }); + try { + // SDK transport in sessionless mode handles initialize internally; we + // can drive it via the SDK Client. + const { Client } = + await import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = + await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const client = new Client( + { name: 't', version: '1' }, + { capabilities: {} } + ); + await client.connect(new StreamableHTTPClientTransport(new URL(srv.url))); + await client.listTools(); + await client.close(); + expect(srv.recorded.map((r) => r.method)).toEqual(['tools/list']); + } finally { + await srv.close(); + } + }); +}); diff --git a/src/mock-server/select.ts b/src/mock-server/select.ts new file mode 100644 index 0000000..201ffeb --- /dev/null +++ b/src/mock-server/select.ts @@ -0,0 +1,26 @@ +import type { SpecVersion } from '../types'; +import type { MockServer, MockServerOptions, RequestHandlers } from './index'; +import { createServerStateful } from './stateful'; +import { createServerStateless } from './stateless'; + +/** + * Spec versions that use the stateful lifecycle (initialize handshake). + * Kept identical to `connection/select.ts`. + */ +const STATEFUL_VERSIONS: ReadonlySet = new Set([ + '2024-11-05', + '2025-03-26', + '2025-06-18', + '2025-11-25' +]); + +export function createServerFor( + specVersion: SpecVersion +): ( + handlers: RequestHandlers, + opts?: MockServerOptions +) => Promise { + return STATEFUL_VERSIONS.has(specVersion) + ? createServerStateful + : createServerStateless; +} diff --git a/src/mock-server/stateful.ts b/src/mock-server/stateful.ts new file mode 100644 index 0000000..469cff1 --- /dev/null +++ b/src/mock-server/stateful.ts @@ -0,0 +1,111 @@ +/** + * Stateful mock server: 2025-x lifecycle (initialize handshake). + * + * Backed by the SDK's `Server` + `StreamableHTTPServerTransport` so we don't + * reimplement the handshake or SSE response framing. The SDK is the scaffold + * here, not the system-under-test; the client-under-test connecting to this + * mock is what's being verified. + */ + +import express from 'express'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { JSONRPCRequest } from '../spec-types/2025-11-25'; +import type { MockServer, MockServerOptions, RequestHandlers } from './index'; + +export async function createServerStateful( + handlers: RequestHandlers, + opts: MockServerOptions = {} +): Promise { + const recorded: JSONRPCRequest[] = []; + const capabilities = opts.capabilities ?? { tools: {} }; + + // Fresh SDK Server per HTTP request (the SDK transport is single-shot in + // sessionless mode after GHSA-345p-7cg4-v4c7). + function newServer(): Server { + const server = new Server( + { name: 'conformance-mock-server', version: '1.0.0' }, + { capabilities } + ); + for (const [method, handler] of Object.entries(handlers)) { + // The SDK's setRequestHandler matches by parsing against the schema's + // method literal; build a minimal schema so any method string works. + const schema = z.object({ + method: z.literal(method), + params: z.unknown().optional() + }); + server.setRequestHandler(schema, async (request) => { + recorded.push(request as JSONRPCRequest); + try { + return (await handler( + (request.params ?? {}) as Record, + request as JSONRPCRequest + )) as Record; + } catch (e) { + if (e instanceof McpError) throw e; + throw new McpError( + ErrorCode.InternalError, + e instanceof Error ? e.message : String(e) + ); + } + }); + } + return server; + } + + const app = express(); + app.use(express.json()); + opts.configure?.(app); + + app.post('/mcp', async (req, res) => { + const server = newServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + transport.close(); + server.close(); + }); + } catch (e) { + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + id: req.body?.id ?? null, + error: { code: -32603, message: String(e) } + }); + } + } + }); + + return listen(app, recorded); +} + +function listen( + app: express.Application, + recorded: JSONRPCRequest[] +): Promise { + return new Promise((resolve, reject) => { + const httpServer = app.listen(0); + httpServer.on('error', reject); + httpServer.on('listening', () => { + const addr = httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const baseUrl = `http://localhost:${port}`; + resolve({ + url: `${baseUrl}/mcp`, + baseUrl, + recorded, + close: () => + new Promise((res) => { + httpServer.closeAllConnections?.(); + httpServer.close(() => res()); + }) + }); + }); + }); +} diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts new file mode 100644 index 0000000..d3882a6 --- /dev/null +++ b/src/mock-server/stateless.ts @@ -0,0 +1,107 @@ +/** + * Stateless mock server: 2026-x lifecycle (SEP-2575). + * + * No initialize handshake. Validates `_meta` (protocolVersion / clientInfo / + * clientCapabilities) and the `MCP-Protocol-Version` header on every request, + * serves `server/discover`, and routes other methods to the supplied handlers. + * Implemented with raw express so it can front-run SDK support. + */ + +import express from 'express'; +import { DRAFT_PROTOCOL_VERSION } from '../types'; +import type { JSONRPCRequest } from '../spec-types/2025-11-25'; +import type { MockServer, MockServerOptions, RequestHandlers } from './index'; + +const META_KEYS = [ + 'io.modelcontextprotocol/protocolVersion', + 'io.modelcontextprotocol/clientInfo', + 'io.modelcontextprotocol/clientCapabilities' +] as const; + +export async function createServerStateless( + handlers: RequestHandlers, + opts: MockServerOptions = {} +): Promise { + const recorded: JSONRPCRequest[] = []; + const capabilities = opts.capabilities ?? { tools: {} }; + + const app = express(); + app.use(express.json()); + opts.configure?.(app); + + app.post('/mcp', async (req, res) => { + const body = req.body ?? {}; + const id = body.id ?? null; + const method: string = body.method; + const params = (body.params ?? {}) as Record; + const meta = params._meta as Record | undefined; + + const error = (status: number, code: number, message: string) => + res.status(status).json({ jsonrpc: '2.0', id, error: { code, message } }); + + const headerVersion = req.headers['mcp-protocol-version']; + if (!headerVersion) { + return error(400, -32001, 'Missing MCP-Protocol-Version header'); + } + const missing = META_KEYS.filter((k) => meta?.[k] === undefined); + if (missing.length > 0) { + return error( + 400, + -32602, + `Invalid params: missing _meta keys: ${missing.join(', ')}` + ); + } + if (meta?.[META_KEYS[0]] !== headerVersion) { + return error( + 400, + -32001, + 'MCP-Protocol-Version header does not match _meta.protocolVersion' + ); + } + + if (method === 'server/discover') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities, + serverInfo: { name: 'conformance-mock-server', version: '1.0.0' } + } + }); + } + + recorded.push(body as JSONRPCRequest); + + const handler = handlers[method]; + if (!handler) { + return error(404, -32601, `Method not found: ${method}`); + } + try { + const result = await handler(params, body as JSONRPCRequest); + return res.json({ jsonrpc: '2.0', id, result }); + } catch (e) { + return error(500, -32603, e instanceof Error ? e.message : String(e)); + } + }); + + return new Promise((resolve, reject) => { + const httpServer = app.listen(0); + httpServer.on('error', reject); + httpServer.on('listening', () => { + const addr = httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + const baseUrl = `http://localhost:${port}`; + resolve({ + url: `${baseUrl}/mcp`, + baseUrl, + recorded, + close: () => + new Promise((r) => { + httpServer.closeAllConnections?.(); + httpServer.close(() => r()); + }) + }); + }); + }); +} diff --git a/src/mock-server/testing.ts b/src/mock-server/testing.ts new file mode 100644 index 0000000..464fca9 --- /dev/null +++ b/src/mock-server/testing.ts @@ -0,0 +1,18 @@ +import { LATEST_SPEC_VERSION, type SpecVersion } from '../types'; +import { createServerFor } from './select'; +import type { ScenarioContext } from './index'; + +/** + * Build a ScenarioContext for unit tests that drive a Scenario directly. + * Defaults to the latest dated spec version (stateful lifecycle) so existing + * tests keep their pre-ScenarioContext behaviour. + */ +export function testScenarioContext( + specVersion: SpecVersion = LATEST_SPEC_VERSION +): ScenarioContext { + return { + specVersion, + createServer: (handlers, opts) => + createServerFor(specVersion)(handlers, opts) + }; +} From 64ad717e7e1c03a8ad8a4a625f84fbd3090f624f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 26 May 2026 16:21:39 +0000 Subject: [PATCH 2/6] refactor: thread ScenarioContext through Scenario.start() Scenario.start() becomes start(ctx: ScenarioContext). The runner builds the context from --spec-version (defaulting to LATEST_SPEC_VERSION) and passes it through; scenarios receive it as _ctx and otherwise behave identically. No scenario uses ctx.createServer() yet, so behaviour is unchanged: 231/231 tests pass. Test files use a testScenarioContext() helper. The runner already threads MCP_CONFORMANCE_PROTOCOL_VERSION to the spawned client process, so the fixture-side env wiring is unchanged. --- src/runner/client.ts | 20 ++++++++++++++++--- .../auth/authorization-server-migration.ts | 3 ++- src/scenarios/client/auth/basic-cimd.ts | 3 ++- .../client/auth/client-credentials.ts | 5 +++-- .../client/auth/discovery-metadata.ts | 3 ++- .../auth/enterprise-managed-authorization.ts | 3 ++- src/scenarios/client/auth/issuer-parameter.ts | 15 +++++++------- .../client/auth/march-spec-backcompat.ts | 5 +++-- src/scenarios/client/auth/offline-access.ts | 5 +++-- src/scenarios/client/auth/pre-registration.ts | 3 ++- .../client/auth/resource-mismatch.ts | 3 ++- src/scenarios/client/auth/scope-handling.ts | 11 +++++----- .../client/auth/test_helpers/testClient.ts | 3 ++- .../client/auth/token-endpoint-auth.ts | 3 ++- src/scenarios/client/elicitation-defaults.ts | 3 ++- src/scenarios/client/http-base.ts | 3 ++- .../client/http-custom-headers.test.ts | 11 +++++----- src/scenarios/client/http-custom-headers.ts | 5 +++-- .../client/http-standard-headers.test.ts | 7 ++++--- src/scenarios/client/initialize.ts | 3 ++- .../client/json-schema-ref-deref.test.ts | 3 ++- src/scenarios/client/json-schema-ref-deref.ts | 3 ++- src/scenarios/client/mrtr-client.ts | 3 ++- src/scenarios/client/request-metadata.test.ts | 3 ++- src/scenarios/client/request-metadata.ts | 3 ++- src/scenarios/client/sse-retry.ts | 3 ++- src/scenarios/client/tools_call.ts | 3 ++- src/types.ts | 3 ++- 28 files changed, 91 insertions(+), 50 deletions(-) diff --git a/src/runner/client.ts b/src/runner/client.ts index 1bf8c9f..f2b2e5f 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -1,8 +1,9 @@ import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import path from 'path'; -import { ConformanceCheck, SpecVersion } from '../types'; +import { ConformanceCheck, SpecVersion, LATEST_SPEC_VERSION } from '../types'; import { getScenario } from '../scenarios'; +import { createServerFor, type ScenarioContext } from '../mock-server'; import { createResultDir, formatPrettyChecks } from './utils'; export interface ClientExecutionResult { @@ -114,8 +115,15 @@ export async function runConformanceTest( // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; + const resolvedVersion = specVersion ?? LATEST_SPEC_VERSION; + const ctx: ScenarioContext = { + specVersion: resolvedVersion, + createServer: (handlers, opts) => + createServerFor(resolvedVersion)(handlers, opts) + }; + console.error(`Starting scenario: ${scenarioName}`); - const urls = await scenario.start(); + const urls = await scenario.start(ctx); console.error(`Executing client: ${clientCommand} ${urls.serverUrl}`); if (urls.context) { @@ -281,8 +289,14 @@ export async function runInteractiveMode( // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; + const ctx: ScenarioContext = { + specVersion: LATEST_SPEC_VERSION, + createServer: (handlers, opts) => + createServerFor(LATEST_SPEC_VERSION)(handlers, opts) + }; + console.log(`Starting scenario: ${scenarioName}`); - const urls = await scenario.start(); + const urls = await scenario.start(ctx); console.log(`Server URL: ${urls.serverUrl}`); console.log('Press Ctrl+C to stop...'); diff --git a/src/scenarios/client/auth/authorization-server-migration.ts b/src/scenarios/client/auth/authorization-server-migration.ts index 858bddb..210a073 100644 --- a/src/scenarios/client/auth/authorization-server-migration.ts +++ b/src/scenarios/client/auth/authorization-server-migration.ts @@ -6,6 +6,7 @@ * to AS₂. On the next 401 the client re-discovers PRM, sees a new issuer, and * MUST re-register with AS₂ rather than reuse AS₁'s client credentials. */ +import type { ScenarioContext } from '../../../mock-server'; import type { Request, Response, NextFunction } from 'express'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; @@ -25,7 +26,7 @@ export class AuthorizationServerMigrationScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']); diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts index a99b4e3..955be83 100644 --- a/src/scenarios/client/auth/basic-cimd.ts +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -29,7 +30,7 @@ export class AuthBasicCIMDScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const authApp = createAuthServer(this.checks, this.authServer.getUrl, { diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index eaebcb0..29aa1cb 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; @@ -42,7 +43,7 @@ export class ClientCredentialsJwtScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; // Generate a fresh keypair for this test run @@ -263,7 +264,7 @@ export class ClientCredentialsBasicScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const authApp = createAuthServer(this.checks, this.authServer.getUrl, { diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index 7387ead..cbaed6b 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -6,6 +6,7 @@ * generated from them. */ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -94,7 +95,7 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { **OAuth metadata:** ${config.oauthMetadataLocation} `, - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { checks = []; const authApp = createAuthServer(checks, authServer.getUrl, { diff --git a/src/scenarios/client/auth/enterprise-managed-authorization.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts index ddadfbf..93b6d5a 100644 --- a/src/scenarios/client/auth/enterprise-managed-authorization.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import * as jose from 'jose'; import type { CryptoKey } from 'jose'; import express, { type Request, type Response } from 'express'; @@ -69,7 +70,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { private idpPrivateKey?: CryptoKey; private grantKeypairs: Map = new Map(); - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; // Generate IDP keypair diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index ecb2bc2..d71d302 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types.js'; import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; @@ -30,7 +31,7 @@ export class IssParameterSupportedScenario implements Scenario { private checks: ConformanceCheck[] = []; private tokenRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.tokenRequestMade = false; @@ -99,7 +100,7 @@ export class IssParameterNotAdvertisedScenario implements Scenario { private checks: ConformanceCheck[] = []; private tokenRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.tokenRequestMade = false; @@ -170,7 +171,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -256,7 +257,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -343,7 +344,7 @@ export class IssParameterUnexpectedScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -435,7 +436,7 @@ export class IssParameterNormalizedVariantScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -522,7 +523,7 @@ export class MetadataIssuerMismatchScenario implements Scenario { private checks: ConformanceCheck[] = []; private metadataEndpointsUsed = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.metadataEndpointsUsed = false; diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index b250209..cdbcd74 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -17,7 +18,7 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; // Legacy server, so we create the auth server endpoints on the // same URL as the main server (rather than separating AS / RS). @@ -81,7 +82,7 @@ export class Auth20250326OEndpointFallbackScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const app = createServer( diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts index afcd593..c66a7c3 100644 --- a/src/scenarios/client/auth/offline-access.ts +++ b/src/scenarios/client/auth/offline-access.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -33,7 +34,7 @@ export class OfflineAccessScopeScenario implements Scenario { private grantTypesChecked = false; private capturedCimdUrl: string | undefined; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.grantTypesChecked = false; this.capturedCimdUrl = undefined; @@ -235,7 +236,7 @@ export class OfflineAccessNotSupportedScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, [ diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index b482e4f..3d9f4ea 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; import { createServer } from './helpers/createServer'; @@ -27,7 +28,7 @@ export class PreRegistrationScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, []); diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index 9c15d4d..d011c8c 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types.js'; import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; @@ -37,7 +38,7 @@ export class ResourceMismatchScenario implements Scenario { private checks: ConformanceCheck[] = []; private authorizationRequestMade = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.authorizationRequestMade = false; diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index c24d51d..9c0bf66 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types'; import { ScenarioUrls } from '../../../types'; import { createAuthServer } from './helpers/createAuthServer'; @@ -22,7 +23,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const expectedScope = 'mcp:basic'; @@ -108,7 +109,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const scopesSupported = ['mcp:basic', 'mcp:read', 'mcp:write']; @@ -204,7 +205,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -291,7 +292,7 @@ export class ScopeStepUpAuthScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const initialScope = 'mcp:basic'; @@ -528,7 +529,7 @@ export class ScopeRetryLimitScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const requiredScope = 'mcp:admin'; diff --git a/src/scenarios/client/auth/test_helpers/testClient.ts b/src/scenarios/client/auth/test_helpers/testClient.ts index 0dbc8d8..c0cfd33 100644 --- a/src/scenarios/client/auth/test_helpers/testClient.ts +++ b/src/scenarios/client/auth/test_helpers/testClient.ts @@ -1,4 +1,5 @@ import { getScenario } from '../../../index'; +import { testScenarioContext } from '../../../../mock-server/testing'; import { spawn } from 'child_process'; const CLIENT_TIMEOUT = 10000; // 10 seconds for client to complete @@ -103,7 +104,7 @@ export async function runClientAgainstScenario( } // Start the scenario server - const urls = await scenario.start(); + const urls = await scenario.start(testScenarioContext()); const serverUrl = urls.serverUrl; try { diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 79d6296..7700c86 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../../mock-server'; import type { Scenario, ConformanceCheck } from '../../../types.js'; import { ScenarioUrls } from '../../../types.js'; import { createAuthServer } from './helpers/createAuthServer.js'; @@ -62,7 +63,7 @@ class TokenEndpointAuthScenario implements Scenario { this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`; } - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.authorizationResource = undefined; this.tokenResource = undefined; diff --git a/src/scenarios/client/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index c78f349..93b8a9f 100644 --- a/src/scenarios/client/elicitation-defaults.ts +++ b/src/scenarios/client/elicitation-defaults.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * SEP-1034: Elicitation defaults test * Validates that clients properly apply default values for omitted fields @@ -482,7 +483,7 @@ export class ElicitationClientDefaultsScenario implements Scenario { private checks: ConformanceCheck[] = []; private cleanup: (() => void) | null = null; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; const { app, cleanup } = createServer(this.checks); this.app = app; diff --git a/src/scenarios/client/http-base.ts b/src/scenarios/client/http-base.ts index 06c2afa..9f6187d 100644 --- a/src/scenarios/client/http-base.ts +++ b/src/scenarios/client/http-base.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios. * @@ -27,7 +28,7 @@ export abstract class BaseHttpScenario implements Scenario { protected port: number = 0; protected sessionId: string = `session-${Date.now()}`; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); diff --git a/src/scenarios/client/http-custom-headers.test.ts b/src/scenarios/client/http-custom-headers.test.ts index 88714ee..8a68f22 100644 --- a/src/scenarios/client/http-custom-headers.test.ts +++ b/src/scenarios/client/http-custom-headers.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, it, expect } from 'vitest'; import { HttpCustomHeadersScenario, @@ -43,7 +44,7 @@ function statusesFor( describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { it('emits exactly the declared requirement IDs as FAILURE when the client never connects', async () => { const scenario = new HttpCustomHeadersScenario(); - await scenario.start(); + await scenario.start(testScenarioContext()); try { const checks = scenario.getChecks(); expect(idsOf(checks)).toEqual(new Set(CUSTOM_HEADERS_DECLARED_CHECK_IDS)); @@ -57,7 +58,7 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { it('maps each parameter kind to its requirement ID on a conforming tool call', async () => { const scenario = new HttpCustomHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { const nonAscii = 'Hello, 世界'; const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64'); @@ -123,7 +124,7 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { it('FAILs client-mirrors-designated-params when an annotated header is missing', async () => { const scenario = new HttpCustomHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await post( serverUrl, @@ -154,7 +155,7 @@ describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => { describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => { it('emits every x-mcp-header constraint ID, SUCCESS when only valid_tool is called', async () => { const scenario = new HttpInvalidToolHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); await post(serverUrl, { @@ -176,7 +177,7 @@ describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => { it('FAILs the violated constraint ID when the client calls an invalid tool', async () => { const scenario = new HttpInvalidToolHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' }); await post(serverUrl, { diff --git a/src/scenarios/client/http-custom-headers.ts b/src/scenarios/client/http-custom-headers.ts index e91cf2c..25e3661 100644 --- a/src/scenarios/client/http-custom-headers.ts +++ b/src/scenarios/client/http-custom-headers.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * HTTP Custom Headers conformance test scenario for MCP clients (SEP-2243) * @@ -186,8 +187,8 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario { private toolCallReceived: boolean = false; private nullToolCallReceived: boolean = false; - async start(): Promise { - const urls = await super.start(); + async start(_ctx: ScenarioContext): Promise { + const urls = await super.start(_ctx); // Pass test values via context for encoding edge cases. // The conformance client should use these values when calling test_custom_headers. urls.context = { diff --git a/src/scenarios/client/http-standard-headers.test.ts b/src/scenarios/client/http-standard-headers.test.ts index 8c5ff6a..3478e06 100644 --- a/src/scenarios/client/http-standard-headers.test.ts +++ b/src/scenarios/client/http-standard-headers.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, it, expect } from 'vitest'; import { HttpStandardHeadersScenario } from './http-standard-headers'; @@ -38,7 +39,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { it('FAILs the initialize Mcp-Method emission when Mcp-Method is missing', async () => { const scenario = new HttpStandardHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await postInitialize(serverUrl, {}); // no Mcp-Method header const checks = scenario.getChecks(); @@ -53,7 +54,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { it('SUCCEEDs the initialize Mcp-Method emission when Mcp-Method matches', async () => { const scenario = new HttpStandardHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); const checks = scenario.getChecks(); @@ -68,7 +69,7 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => { it('getChecks() is idempotent', async () => { const scenario = new HttpStandardHeadersScenario(); - const { serverUrl } = await scenario.start(); + const { serverUrl } = await scenario.start(testScenarioContext()); try { await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); const first = scenario.getChecks(); diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index b5e2aef..0ccd76d 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; import http from 'http'; import { Scenario, @@ -17,7 +18,7 @@ export class InitializeScenario implements Scenario { private checks: ConformanceCheck[] = []; private port: number = 0; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); diff --git a/src/scenarios/client/json-schema-ref-deref.test.ts b/src/scenarios/client/json-schema-ref-deref.test.ts index b11d2d2..a309193 100644 --- a/src/scenarios/client/json-schema-ref-deref.test.ts +++ b/src/scenarios/client/json-schema-ref-deref.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, test, expect } from 'vitest'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -92,7 +93,7 @@ describe('json-schema-ref-no-deref (SEP-2106)', () => { test('client that never lists tools fails: requirement cannot be evaluated', async () => { const scenario = new JsonSchemaRefDerefScenario(); - await scenario.start(); + await scenario.start(testScenarioContext()); try { const checks = scenario.getChecks(); const check = checks.find( diff --git a/src/scenarios/client/json-schema-ref-deref.ts b/src/scenarios/client/json-schema-ref-deref.ts index 91ccc9e..a7b315e 100644 --- a/src/scenarios/client/json-schema-ref-deref.ts +++ b/src/scenarios/client/json-schema-ref-deref.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; @@ -82,7 +83,7 @@ The scenario advertises a tool whose inputSchema contains a \`$ref\` pointing at private canaryRequests: Array<{ method: string; userAgent?: string }> = []; private toolsListed = false; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.canaryRequests = []; this.toolsListed = false; diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index 431fafe..4521ca0 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * SEP-2322: MRTR Client Conformance Tests * @@ -440,7 +441,7 @@ export class MRTRClientScenario implements Scenario { private httpServer: ReturnType | null = null; private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.app = createMRTRServer(this.checks); this.httpServer = this.app.listen(0); diff --git a/src/scenarios/client/request-metadata.test.ts b/src/scenarios/client/request-metadata.test.ts index 6938464..303691a 100644 --- a/src/scenarios/client/request-metadata.test.ts +++ b/src/scenarios/client/request-metadata.test.ts @@ -1,3 +1,4 @@ +import { testScenarioContext } from '../../mock-server/testing'; import { describe, test, expect } from 'vitest'; import { runClientAgainstScenario, @@ -206,7 +207,7 @@ describe('request-metadata client scenario — client never connects', () => { throw new Error('Scenario not found'); } - await scenario.start(); + await scenario.start(testScenarioContext()); try { const checks = scenario.getChecks(); const byId = new Map(checks.map((c) => [c.id, c])); diff --git a/src/scenarios/client/request-metadata.ts b/src/scenarios/client/request-metadata.ts index 47d4f00..0b47285 100644 --- a/src/scenarios/client/request-metadata.ts +++ b/src/scenarios/client/request-metadata.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; import http from 'http'; import { Scenario, @@ -32,7 +33,7 @@ export class RequestMetadataScenario implements Scenario { private hasSimulatedRejection = false; private requestsObserved = 0; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.hasSimulatedRejection = false; this.checks = []; this.requestsObserved = 0; diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index b90bf40..4f577f7 100644 --- a/src/scenarios/client/sse-retry.ts +++ b/src/scenarios/client/sse-retry.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; /** * SSE Retry conformance test scenarios for MCP clients (SEP-1699) * @@ -38,7 +39,7 @@ export class SSERetryScenario implements Scenario { private readonly LATE_TOLERANCE = 200; // Allow 200ms late for network/event loop private readonly VERY_LATE_MULTIPLIER = 2; // If >2x retry value, client is likely ignoring it - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { this.handleRequest(req, res); diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index 59470f3..4d41550 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -1,3 +1,4 @@ +import type { ScenarioContext } from '../../mock-server'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { @@ -121,7 +122,7 @@ export class ToolsCallScenario implements Scenario { private httpServer: any = null; private checks: ConformanceCheck[] = []; - async start(): Promise { + async start(_ctx: ScenarioContext): Promise { this.checks = []; this.app = createServerApp(this.checks); this.httpServer = this.app.listen(0); diff --git a/src/types.ts b/src/types.ts index 6c6376e..2e9dd22 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { RunContext } from './connection'; +import type { ScenarioContext } from './mock-server'; export type CheckStatus = | 'SUCCESS' @@ -102,7 +103,7 @@ export interface Scenario { * Use this for scenarios where the client is expected to error (e.g., rejecting invalid auth). */ allowClientError?: boolean; - start(): Promise; + start(ctx: ScenarioContext): Promise; stop(): Promise; getChecks(): ConformanceCheck[]; } From 7add7b3af81c29781861a7e2ef8a3bb9a5c1d099 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 26 May 2026 16:24:45 +0000 Subject: [PATCH 3/6] refactor: migrate tools_call to ctx.createServer; tag 2025-only client scenarios ToolsCallScenario now goes through ctx.createServer() instead of an inline express + SDK Server build. Same handlers, same checks; the assertion now reads from srv.recorded so it works regardless of which lifecycle scaffold the runner picked. initialize, sse-retry, and elicitation-defaults are tagged removedIn: DRAFT (initialize/GET-SSE/SSE-embedded-elicitation are gone in the 2026 lifecycle; the MRTR sibling for elicitation-defaults is a follow-up). spec-version.test.ts: the 'draft is a superset of latest' invariant no longer holds once removedIn: DRAFT exists; the test now asserts that any scenario in latest-but-not-draft is explicitly removedIn. --- src/scenarios/client/elicitation-defaults.ts | 7 +- src/scenarios/client/initialize.ts | 8 +- src/scenarios/client/sse-retry.ts | 12 +- src/scenarios/client/tools_call.ts | 211 ++++++------------- src/scenarios/spec-version.test.ts | 11 +- 5 files changed, 94 insertions(+), 155 deletions(-) diff --git a/src/scenarios/client/elicitation-defaults.ts b/src/scenarios/client/elicitation-defaults.ts index 93b8a9f..d417029 100644 --- a/src/scenarios/client/elicitation-defaults.ts +++ b/src/scenarios/client/elicitation-defaults.ts @@ -14,7 +14,7 @@ import { } from '@modelcontextprotocol/sdk/types.js'; import type { Scenario, ConformanceCheck } from '../../types'; import express, { Request, Response } from 'express'; -import { ScenarioUrls } from '../../types'; +import { ScenarioUrls, DRAFT_PROTOCOL_VERSION } from '../../types'; import { createRequestLogger } from '../request-logger'; import { randomUUID } from 'crypto'; @@ -475,7 +475,10 @@ function createServer(checks: ConformanceCheck[]): { export class ElicitationClientDefaultsScenario implements Scenario { name = 'elicitation-sep1034-client-defaults'; - readonly source = { introducedIn: '2025-11-25' } as const; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Tests client applies default values for omitted elicitation fields (SEP-1034)'; private app: express.Application | null = null; diff --git a/src/scenarios/client/initialize.ts b/src/scenarios/client/initialize.ts index 0ccd76d..be8d708 100644 --- a/src/scenarios/client/initialize.ts +++ b/src/scenarios/client/initialize.ts @@ -5,13 +5,17 @@ import { ScenarioUrls, ConformanceCheck, LATEST_SPEC_VERSION, - NEGOTIABLE_PROTOCOL_VERSIONS + NEGOTIABLE_PROTOCOL_VERSIONS, + DRAFT_PROTOCOL_VERSION } from '../../types'; import { clientChecks } from '../../checks/index'; export class InitializeScenario implements Scenario { name = 'initialize'; - readonly source = { introducedIn: '2025-06-18' } as const; + readonly source = { + introducedIn: '2025-06-18', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Tests MCP client initialization handshake'; private server: http.Server | null = null; diff --git a/src/scenarios/client/sse-retry.ts b/src/scenarios/client/sse-retry.ts index 4f577f7..e89b23e 100644 --- a/src/scenarios/client/sse-retry.ts +++ b/src/scenarios/client/sse-retry.ts @@ -9,11 +9,19 @@ import type { ScenarioContext } from '../../mock-server'; */ import http from 'http'; -import { Scenario, ScenarioUrls, ConformanceCheck } from '../../types.js'; +import { + Scenario, + ScenarioUrls, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION +} from '../../types.js'; export class SSERetryScenario implements Scenario { name = 'sse-retry'; - readonly source = { introducedIn: '2025-11-25' } as const; + readonly source = { + introducedIn: '2025-11-25', + removedIn: DRAFT_PROTOCOL_VERSION + } as const; description = 'Tests that client respects SSE retry field timing and reconnects properly (SEP-1699)'; diff --git a/src/scenarios/client/tools_call.ts b/src/scenarios/client/tools_call.ts index 4d41550..31be083 100644 --- a/src/scenarios/client/tools_call.ts +++ b/src/scenarios/client/tools_call.ts @@ -1,165 +1,82 @@ -import type { ScenarioContext } from '../../mock-server'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; -import type { Scenario, ConformanceCheck } from '../../types'; -import express, { Request, Response } from 'express'; -import { ScenarioUrls } from '../../types'; -import { createRequestLogger } from '../request-logger'; +import type { ScenarioContext, MockServer } from '../../mock-server'; +import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../types'; +import type { CallToolRequest } from '../../spec-types/2025-06-18'; -function createMcpServer(checks: ConformanceCheck[]): Server { - const server = new Server( - { - name: 'add-numbers-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'add_numbers', - description: 'Add two numbers together', - inputSchema: { - type: 'object', - properties: { - a: { - type: 'number', - description: 'First number' - }, - b: { - type: 'number', - description: 'Second number' - } - }, - required: ['a', 'b'] - } - } - ] - }; - }); - - server.setRequestHandler(CallToolRequestSchema, async (request) => { - if (request.params.name === 'add_numbers') { - const { a, b } = request.params.arguments as { a: number; b: number }; - const result = a + b; - - checks.push({ - id: 'tool-add-numbers', - name: 'ToolAddNumbers', - description: 'Validates that the add_numbers tool works correctly', - status: 'SUCCESS', - timestamp: new Date().toISOString(), - specReferences: [ - { - id: 'MCP-Tools', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' - } - ], - details: { - a, - b, - result - } - }); - - return { - content: [ - { - type: 'text', - text: `The sum of ${a} and ${b} is ${result}` - } - ] - }; - } - - throw new Error(`Unknown tool: ${request.params.name}`); - }); - - return server; -} - -function createServerApp(checks: ConformanceCheck[]): express.Application { - const app = express(); - app.use(express.json()); - - app.use( - createRequestLogger(checks, { - incomingId: 'incoming-request', - outgoingId: 'outgoing-response', - mcpRoute: '/mcp' - }) - ); - - app.post('/mcp', async (req: Request, res: Response) => { - // Stateless: create a fresh server and transport per request - const server = createMcpServer(checks); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); - await server.connect(transport); - - await transport.handleRequest(req, res, req.body); - }); - - return app; -} +const SPEC_REF = { + id: 'MCP-Tools', + url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' +}; export class ToolsCallScenario implements Scenario { name = 'tools_call'; readonly source = { introducedIn: '2025-06-18' } as const; description = 'Tests calling tools with various parameter types'; - private app: express.Application | null = null; - private httpServer: any = null; + private srv: MockServer | null = null; private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; - this.app = createServerApp(this.checks); - this.httpServer = this.app.listen(0); - const port = this.httpServer.address().port; - return { serverUrl: `http://localhost:${port}/mcp` }; + this.srv = await ctx.createServer({ + 'tools/list': () => ({ + tools: [ + { + name: 'add_numbers', + description: 'Add two numbers together', + inputSchema: { + type: 'object', + properties: { + a: { type: 'number', description: 'First number' }, + b: { type: 'number', description: 'Second number' } + }, + required: ['a', 'b'] + } + } + ] + }), + 'tools/call': (params) => { + const p = params as CallToolRequest['params']; + if (p.name !== 'add_numbers') { + throw new Error(`Unknown tool: ${p.name}`); + } + const { a, b } = p.arguments as { a: number; b: number }; + return { + content: [ + { type: 'text', text: `The sum of ${a} and ${b} is ${a + b}` } + ] + }; + } + }); + return { serverUrl: this.srv.url }; } async stop() { - if (this.httpServer) { - await new Promise((resolve) => this.httpServer.close(resolve)); - this.httpServer = null; - } - this.app = null; + await this.srv?.close(); + this.srv = null; } getChecks(): ConformanceCheck[] { - const expectedSlugs = ['tool-add-numbers']; - // add a failure if not in there already - for (const slug of expectedSlugs) { - if (!this.checks.find((c) => c.id === slug)) { - // TODO: this is duplicated from above, refactor - this.checks.push({ - id: slug, - name: `ToolAddNumbers`, - description: `Validates that the add_numbers tool works correctly`, - status: 'FAILURE', - timestamp: new Date().toISOString(), - details: { message: 'Tool was not called by client' }, - specReferences: [ - { - id: 'MCP-Tools', - url: 'https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools' - } - ] - }); - } - } + const call = this.srv?.recorded.find((r) => r.method === 'tools/call'); + const args = (call?.params as CallToolRequest['params'] | undefined) + ?.arguments as { a?: unknown; b?: unknown } | undefined; + const ok = + call !== undefined && + typeof args?.a === 'number' && + typeof args?.b === 'number'; + this.checks.push({ + id: 'tool-add-numbers', + name: 'ToolAddNumbers', + description: 'Validates that the add_numbers tool works correctly', + status: ok ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + specReferences: [SPEC_REF], + details: ok + ? { + a: args!.a, + b: args!.b, + result: (args!.a as number) + (args!.b as number) + } + : { message: 'Tool was not called by client' } + }); return this.checks; } } diff --git a/src/scenarios/spec-version.test.ts b/src/scenarios/spec-version.test.ts index c77e43e..e39f06c 100644 --- a/src/scenarios/spec-version.test.ts +++ b/src/scenarios/spec-version.test.ts @@ -4,6 +4,7 @@ import { listScenariosForSpec, listDraftScenarios, listExtensionScenarios, + getScenario, getScenarioSpecVersions, resolveSpecVersion, ALL_SPEC_VERSIONS, @@ -54,11 +55,17 @@ describe('specVersions helpers', () => { expect(current.length).toBeGreaterThan(overlap.length); }); - it('the draft spec version is a superset of the latest dated release', () => { + it('every scenario in latest but not in draft is explicitly removedIn: DRAFT', () => { const latest = new Set(listScenariosForSpec(LATEST_SPEC_VERSION)); const draft = new Set(listScenariosForSpec(DRAFT_PROTOCOL_VERSION)); for (const name of latest) { - expect(draft.has(name)).toBe(true); + if (!draft.has(name)) { + const s = getScenario(name)!; + expect( + 'removedIn' in s.source && s.source.removedIn, + `"${name}" is in ${LATEST_SPEC_VERSION} but not in draft without removedIn` + ).toBe(DRAFT_PROTOCOL_VERSION); + } } for (const name of listDraftScenarios()) { expect(draft.has(name)).toBe(true); From 24b164e7e52660f10b22bee6c4beb78c2b08ffa2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 26 May 2026 16:26:16 +0000 Subject: [PATCH 4/6] feat(auth): make createServer helper version-aware via ScenarioContext The auth helper now takes ctx: ScenarioContext as its first argument and branches on ctx.specVersion inside the /mcp route: the stateful path (SDK Server + StreamableHTTPServerTransport) is unchanged; under the draft version a raw stateless handler validates _meta + the MCP-Protocol-Version header, serves server/discover, and routes the same tools/list and tools/call responses. The PRM endpoint, bearer-auth middleware, and request logger sit above the branch and are version-independent. All 25 call sites across the 12 auth scenario files pass ctx through; ServerLifecycle and the express.Application return type are unchanged so stop()/getChecks() are untouched. Deviation from the MockServer wrapper approach: keeping the helper's return type as express.Application avoids restructuring 25 call sites' ServerLifecycle handling in this PR. Folding the auth seam onto ctx.createServer() fully is a follow-up once the lifecycle ownership moves into MockServer. --- .../auth/authorization-server-migration.ts | 3 +- src/scenarios/client/auth/basic-cimd.ts | 3 +- .../client/auth/client-credentials.ts | 6 +- .../client/auth/discovery-metadata.ts | 4 +- .../auth/enterprise-managed-authorization.ts | 3 +- .../client/auth/helpers/createServer.ts | 66 +++++++++++++++++++ src/scenarios/client/auth/issuer-parameter.ts | 21 ++++-- .../client/auth/march-spec-backcompat.ts | 6 +- src/scenarios/client/auth/offline-access.ts | 6 +- src/scenarios/client/auth/pre-registration.ts | 3 +- .../client/auth/resource-mismatch.ts | 3 +- src/scenarios/client/auth/scope-handling.ts | 15 +++-- .../client/auth/token-endpoint-auth.ts | 3 +- 13 files changed, 116 insertions(+), 26 deletions(-) diff --git a/src/scenarios/client/auth/authorization-server-migration.ts b/src/scenarios/client/auth/authorization-server-migration.ts index 210a073..9fe8add 100644 --- a/src/scenarios/client/auth/authorization-server-migration.ts +++ b/src/scenarios/client/auth/authorization-server-migration.ts @@ -26,7 +26,7 @@ export class AuthorizationServerMigrationScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, ['mcp:basic']); @@ -126,6 +126,7 @@ export class AuthorizationServerMigrationScenario implements Scenario { }; const app = createServer( + ctx, this.checks, this.server.getUrl, currentAuthServerUrl, diff --git a/src/scenarios/client/auth/basic-cimd.ts b/src/scenarios/client/auth/basic-cimd.ts index 955be83..7a9969f 100644 --- a/src/scenarios/client/auth/basic-cimd.ts +++ b/src/scenarios/client/auth/basic-cimd.ts @@ -30,7 +30,7 @@ export class AuthBasicCIMDScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const authApp = createAuthServer(this.checks, this.authServer.getUrl, { @@ -61,6 +61,7 @@ export class AuthBasicCIMDScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl diff --git a/src/scenarios/client/auth/client-credentials.ts b/src/scenarios/client/auth/client-credentials.ts index 29aa1cb..1171e5f 100644 --- a/src/scenarios/client/auth/client-credentials.ts +++ b/src/scenarios/client/auth/client-credentials.ts @@ -43,7 +43,7 @@ export class ClientCredentialsJwtScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Generate a fresh keypair for this test run @@ -202,6 +202,7 @@ export class ClientCredentialsJwtScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl @@ -264,7 +265,7 @@ export class ClientCredentialsBasicScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const authApp = createAuthServer(this.checks, this.authServer.getUrl, { @@ -365,6 +366,7 @@ export class ClientCredentialsBasicScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl diff --git a/src/scenarios/client/auth/discovery-metadata.ts b/src/scenarios/client/auth/discovery-metadata.ts index cbaed6b..16b3dd9 100644 --- a/src/scenarios/client/auth/discovery-metadata.ts +++ b/src/scenarios/client/auth/discovery-metadata.ts @@ -95,7 +95,7 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { **OAuth metadata:** ${config.oauthMetadataLocation} `, - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { checks = []; const authApp = createAuthServer(checks, authServer.getUrl, { @@ -132,7 +132,7 @@ function createMetadataScenario(config: MetadataScenarioConfig): Scenario { ? () => `${authServer.getUrl()}${routePrefix}` : authServer.getUrl; - const app = createServer(checks, server.getUrl, getAuthServerUrl, { + const app = createServer(ctx, checks, server.getUrl, getAuthServerUrl, { prmPath: config.prmLocation, includePrmInWwwAuth: config.inWwwAuth }); diff --git a/src/scenarios/client/auth/enterprise-managed-authorization.ts b/src/scenarios/client/auth/enterprise-managed-authorization.ts index 93b6d5a..b89a247 100644 --- a/src/scenarios/client/auth/enterprise-managed-authorization.ts +++ b/src/scenarios/client/auth/enterprise-managed-authorization.ts @@ -70,7 +70,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { private idpPrivateKey?: CryptoKey; private grantKeypairs: Map = new Map(); - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Generate IDP keypair @@ -121,6 +121,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario { // Start MCP server with shared token verifier const mcpApp = createServer( + ctx, this.checks, this.mcpServer.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index c836630..e5681cd 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -10,6 +10,8 @@ import { import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types'; +import { DRAFT_PROTOCOL_VERSION } from '../../../../types'; +import type { ScenarioContext } from '../../../../mock-server'; import { createRequestLogger } from '../../../request-logger'; import { MockTokenVerifier } from './mockTokenVerifier'; import { SpecReferences } from '../spec-references'; @@ -27,6 +29,7 @@ export interface ServerOptions { } export function createServer( + ctx: ScenarioContext, checks: ConformanceCheck[], getBaseUrl: () => string, getAuthServerUrl: () => string, @@ -157,6 +160,9 @@ export function createServer( authMiddleware(req, res, async (err?: any) => { if (err) return next(err); + if (ctx.specVersion === DRAFT_PROTOCOL_VERSION) { + return handleStateless(req, res); + } const server = createMcpServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined @@ -186,5 +192,65 @@ export function createServer( }); }); + // Stateless lifecycle for the /mcp route: validate _meta + header, serve + // server/discover, route the same tools handlers as createMcpServer. + // The bearer-auth middleware and PRM route above are version-independent. + function handleStateless(req: Request, res: Response) { + const body = req.body ?? {}; + const id = body.id ?? null; + const meta = body.params?._meta; + const hv = req.headers['mcp-protocol-version']; + if (!hv) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { code: -32001, message: 'Missing MCP-Protocol-Version header' } + }); + } + if ( + !meta?.['io.modelcontextprotocol/protocolVersion'] || + !meta?.['io.modelcontextprotocol/clientInfo'] || + !meta?.['io.modelcontextprotocol/clientCapabilities'] + ) { + return res.status(400).json({ + jsonrpc: '2.0', + id, + error: { code: -32602, message: 'Invalid params: missing _meta keys' } + }); + } + if (body.method === 'server/discover') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + supportedVersions: [DRAFT_PROTOCOL_VERSION], + capabilities: { tools: {} }, + serverInfo: { name: 'auth-prm-pathbased-server', version: '1.0.0' } + } + }); + } + if (body.method === 'tools/list') { + return res.json({ + jsonrpc: '2.0', + id, + result: { + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + } + }); + } + if (body.method === 'tools/call') { + return res.json({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'test' }] } + }); + } + return res.status(404).json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: 'Method not found' } + }); + } + return app; } diff --git a/src/scenarios/client/auth/issuer-parameter.ts b/src/scenarios/client/auth/issuer-parameter.ts index d71d302..81dac38 100644 --- a/src/scenarios/client/auth/issuer-parameter.ts +++ b/src/scenarios/client/auth/issuer-parameter.ts @@ -31,7 +31,7 @@ export class IssParameterSupportedScenario implements Scenario { private checks: ConformanceCheck[] = []; private tokenRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.tokenRequestMade = false; @@ -49,6 +49,7 @@ export class IssParameterSupportedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -100,7 +101,7 @@ export class IssParameterNotAdvertisedScenario implements Scenario { private checks: ConformanceCheck[] = []; private tokenRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.tokenRequestMade = false; @@ -118,6 +119,7 @@ export class IssParameterNotAdvertisedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -171,7 +173,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -193,6 +195,7 @@ export class IssParameterSupportedMissingScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -257,7 +260,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -279,6 +282,7 @@ export class IssParameterWrongIssuerScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -344,7 +348,7 @@ export class IssParameterUnexpectedScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -366,6 +370,7 @@ export class IssParameterUnexpectedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -436,7 +441,7 @@ export class IssParameterNormalizedVariantScenario implements Scenario { private authReached = false; private tokenRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authReached = false; this.tokenRequestMade = false; @@ -458,6 +463,7 @@ export class IssParameterNormalizedVariantScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -523,7 +529,7 @@ export class MetadataIssuerMismatchScenario implements Scenario { private checks: ConformanceCheck[] = []; private metadataEndpointsUsed = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.metadataEndpointsUsed = false; @@ -556,6 +562,7 @@ export class MetadataIssuerMismatchScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/march-spec-backcompat.ts b/src/scenarios/client/auth/march-spec-backcompat.ts index cdbcd74..68868b0 100644 --- a/src/scenarios/client/auth/march-spec-backcompat.ts +++ b/src/scenarios/client/auth/march-spec-backcompat.ts @@ -18,7 +18,7 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; // Legacy server, so we create the auth server endpoints on the // same URL as the main server (rather than separating AS / RS). @@ -29,6 +29,7 @@ export class Auth20250326OAuthMetadataBackcompatScenario implements Scenario { routePrefix: '/oauth' }); const app = createServer( + ctx, this.checks, this.server.getUrl, this.server.getUrl, @@ -82,10 +83,11 @@ export class Auth20250326OEndpointFallbackScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const app = createServer( + ctx, this.checks, this.server.getUrl, this.server.getUrl, diff --git a/src/scenarios/client/auth/offline-access.ts b/src/scenarios/client/auth/offline-access.ts index c66a7c3..2f927c6 100644 --- a/src/scenarios/client/auth/offline-access.ts +++ b/src/scenarios/client/auth/offline-access.ts @@ -34,7 +34,7 @@ export class OfflineAccessScopeScenario implements Scenario { private grantTypesChecked = false; private capturedCimdUrl: string | undefined; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.grantTypesChecked = false; this.capturedCimdUrl = undefined; @@ -102,6 +102,7 @@ export class OfflineAccessScopeScenario implements Scenario { // PRM does NOT include offline_access (per SEP-2207 server guidance: // servers SHOULD NOT include offline_access in PRM scopes_supported) const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -236,7 +237,7 @@ export class OfflineAccessNotSupportedScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, [ @@ -269,6 +270,7 @@ export class OfflineAccessNotSupportedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/pre-registration.ts b/src/scenarios/client/auth/pre-registration.ts index 3d9f4ea..eefdd03 100644 --- a/src/scenarios/client/auth/pre-registration.ts +++ b/src/scenarios/client/auth/pre-registration.ts @@ -28,7 +28,7 @@ export class PreRegistrationScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -106,6 +106,7 @@ export class PreRegistrationScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/resource-mismatch.ts b/src/scenarios/client/auth/resource-mismatch.ts index d011c8c..e59ebcc 100644 --- a/src/scenarios/client/auth/resource-mismatch.ts +++ b/src/scenarios/client/auth/resource-mismatch.ts @@ -38,7 +38,7 @@ export class ResourceMismatchScenario implements Scenario { private checks: ConformanceCheck[] = []; private authorizationRequestMade = false; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authorizationRequestMade = false; @@ -61,6 +61,7 @@ export class ResourceMismatchScenario implements Scenario { // Create server that returns a mismatched resource in PRM const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/scope-handling.ts b/src/scenarios/client/auth/scope-handling.ts index 9c0bf66..c1154ca 100644 --- a/src/scenarios/client/auth/scope-handling.ts +++ b/src/scenarios/client/auth/scope-handling.ts @@ -23,7 +23,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const expectedScope = 'mcp:basic'; @@ -54,6 +54,7 @@ export class ScopeFromWwwAuthenticateScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -109,7 +110,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const scopesSupported = ['mcp:basic', 'mcp:read', 'mcp:write']; @@ -149,6 +150,7 @@ export class ScopeFromScopesSupportedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -205,7 +207,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const tokenVerifier = new MockTokenVerifier(this.checks, []); @@ -233,6 +235,7 @@ export class ScopeOmittedWhenUndefinedScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -292,7 +295,7 @@ export class ScopeStepUpAuthScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const initialScope = 'mcp:basic'; @@ -438,6 +441,7 @@ export class ScopeStepUpAuthScenario implements Scenario { }; const baseApp = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, @@ -529,7 +533,7 @@ export class ScopeRetryLimitScenario implements Scenario { private server = new ServerLifecycle(); private checks: ConformanceCheck[] = []; - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; const requiredScope = 'mcp:admin'; @@ -614,6 +618,7 @@ export class ScopeRetryLimitScenario implements Scenario { }; const baseApp = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, diff --git a/src/scenarios/client/auth/token-endpoint-auth.ts b/src/scenarios/client/auth/token-endpoint-auth.ts index 7700c86..7b7616f 100644 --- a/src/scenarios/client/auth/token-endpoint-auth.ts +++ b/src/scenarios/client/auth/token-endpoint-auth.ts @@ -63,7 +63,7 @@ class TokenEndpointAuthScenario implements Scenario { this.description = `Tests that client uses ${AUTH_METHOD_NAMES[expectedAuthMethod]} when server only supports ${expectedAuthMethod}`; } - async start(_ctx: ScenarioContext): Promise { + async start(ctx: ScenarioContext): Promise { this.checks = []; this.authorizationResource = undefined; this.tokenResource = undefined; @@ -138,6 +138,7 @@ class TokenEndpointAuthScenario implements Scenario { await this.authServer.start(authApp); const app = createServer( + ctx, this.checks, this.server.getUrl, this.authServer.getUrl, From be9a85cb2ea3e28fdf6e095aaefd2fb47a7674a2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 26 May 2026 16:27:30 +0000 Subject: [PATCH 5/6] feat(everything-client): pick stateless requester by MCP_CONFORMANCE_PROTOCOL_VERSION Adds a statelessRequest(serverUrl, method, params) helper that POSTs with _meta + MCP-Protocol-Version (the SEP-2575 lifecycle), shimming around the SDK Client not yet supporting stateless mode. The runRequestMetadataClient handler's meta constants are extracted to share with the helper. runBasicClient (initialize, tools_call, json-schema-ref-no-deref) now branches on MCP_CONFORMANCE_PROTOCOL_VERSION: for the draft version it uses statelessRequest to call tools/list then tools/call; for dated versions it keeps the SDK Client path. The runner already passes MCP_CONFORMANCE_PROTOCOL_VERSION to the spawned client, so no runner change is needed. --- .../clients/typescript/everything-client.ts | 101 +++++++++++++++--- 1 file changed, 84 insertions(+), 17 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index 74168f0..795a7dc 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -70,10 +70,82 @@ export function getHandler(scenarioName: string): ScenarioHandler | undefined { } // ============================================================================ -// Basic scenarios (initialize, tools-call) +// Stateless requester (SEP-2575 / 2026-x lifecycle) +// +// Shim for the fact that the SDK Client doesn't support stateless mode yet. +// Carry-forward handlers below pick this when MCP_CONFORMANCE_PROTOCOL_VERSION +// is the draft version, so the same handler exercises both lifecycles. +// ============================================================================ + +const PROTOCOL_VERSION = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION; +const DRAFT_VERSION = 'DRAFT-2026-v1'; + +const STATELESS_META_BASE = { + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + tools: {}, + roots: {}, + sampling: {}, + elicitation: {} + } +}; + +let _nextStatelessId = 1; +async function statelessRequest( + serverUrl: string, + method: string, + params: Record = {} +): Promise { + const _meta = { + 'io.modelcontextprotocol/protocolVersion': DRAFT_VERSION, + ...STATELESS_META_BASE, + ...((params._meta as object | undefined) ?? {}) + }; + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': DRAFT_VERSION + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: _nextStatelessId++, + method, + params: { ...params, _meta } + }) + }); + const body = await response.json(); + if (body.error) { + throw new Error( + `${method} failed: ${body.error.code} ${body.error.message}` + ); + } + return body.result; +} + +// ============================================================================ +// Basic scenarios (initialize, tools_call) // ============================================================================ async function runBasicClient(serverUrl: string): Promise { + if (PROTOCOL_VERSION === DRAFT_VERSION) { + logger.debug('Stateless lifecycle: calling tools/list + tools/call'); + const list = await statelessRequest(serverUrl, 'tools/list'); + logger.debug('Successfully listed tools:', JSON.stringify(list)); + const tool = list?.tools?.[0]; + if (tool) { + const result = await statelessRequest(serverUrl, 'tools/call', { + name: tool.name, + arguments: { a: 2, b: 3 } + }); + logger.debug('Successfully called tool:', JSON.stringify(result)); + } + return; + } + const client = new Client( { name: 'test-client', version: '1.0.0' }, { capabilities: {} } @@ -84,14 +156,20 @@ async function runBasicClient(serverUrl: string): Promise { await client.connect(transport); logger.debug('Successfully connected to MCP server'); - await client.listTools(); + const list = await client.listTools(); logger.debug('Successfully listed tools'); + const tool = list.tools[0]; + if (tool) { + await client.callTool({ name: tool.name, arguments: { a: 2, b: 3 } }); + logger.debug('Successfully called tool'); + } + await transport.close(); logger.debug('Connection closed successfully'); } -registerScenarios(['initialize', 'tools-call'], runBasicClient); +registerScenarios(['initialize', 'tools_call', 'tools-call'], runBasicClient); // SEP-2106: json-schema-ref-no-deref advertises a tool whose inputSchema // contains a network-URI $ref. A conformant client lists tools normally and @@ -106,20 +184,9 @@ registerScenario('json-schema-ref-no-deref', runBasicClient); async function runRequestMetadataClient(serverUrl: string): Promise { logger.debug('Starting request-metadata client flow...'); - const meta = { - 'io.modelcontextprotocol/clientInfo': { - name: 'conformance-test-client', - version: '1.0.0' - }, - 'io.modelcontextprotocol/clientCapabilities': { - tools: {}, - roots: {}, - sampling: {}, - elicitation: {} - } - }; + const meta = STATELESS_META_BASE; - let activeVersion = 'DRAFT-2026-v1'; + let activeVersion = DRAFT_VERSION; const sendRequestWithNegotiation = async ( method: string, @@ -161,7 +228,7 @@ async function runRequestMetadataClient(serverUrl: string): Promise { ); const serverSupported: string[] = errorResult.error.data?.supported || []; - const clientSupported = ['DRAFT-2026-v1']; + const clientSupported = [DRAFT_VERSION]; const mutuallySupported = clientSupported.filter((v) => serverSupported.includes(v) ); From 97202523fefccf39c994ebedc4215705581032c4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 26 May 2026 16:54:07 +0000 Subject: [PATCH 6/6] fix: address review findings on MockServer (dead opts, shared validator, capability derivation, recorded parity, specVersion threading) - MockServerOptions removed (capabilities/configure had zero callers); opts param dropped from createServerStateful/Stateless/For and ScenarioContext. - validateStatelessRequest extracted from mock-server/stateless and exported; both the stateless MockServer and auth/helpers/createServer.ts call it so _meta/header/version validation cannot drift. - isStatefulVersion exported from connection/select; mock-server/select uses it instead of duplicating the version set. - runner/client.ts: env MCP_CONFORMANCE_PROTOCOL_VERSION set unconditionally to the resolved version; runInteractiveMode now takes specVersion and the CLI passes it. - createServerStateful: capabilities derived from handler method prefixes; newServer() moved inside the try so a capability mismatch surfaces as JSON-RPC -32603 instead of an HTML 500. Recording moved to the express layer so unregistered methods are captured (parity with stateless). - readFinalSseMessage return type now declares error.data. - Tests added for the capability derivation and unregistered-method recording. --- src/connection/select.ts | 8 +- src/connection/stateless.ts | 5 +- src/index.ts | 7 +- src/mock-server/index.ts | 22 +-- src/mock-server/mock-server.test.ts | 66 ++++++++ src/mock-server/select.ts | 21 +-- src/mock-server/stateful.ts | 52 +++++-- src/mock-server/stateless.ts | 141 ++++++++++++------ src/mock-server/testing.ts | 3 +- src/runner/client.ts | 18 +-- .../client/auth/helpers/createServer.ts | 56 ++----- 11 files changed, 252 insertions(+), 147 deletions(-) diff --git a/src/connection/select.ts b/src/connection/select.ts index a08ec95..27b1868 100644 --- a/src/connection/select.ts +++ b/src/connection/select.ts @@ -14,10 +14,12 @@ const STATEFUL_VERSIONS: ReadonlySet = new Set([ '2025-11-25' ]); +export function isStatefulVersion(v: SpecVersion): boolean { + return STATEFUL_VERSIONS.has(v); +} + export function connectFor( specVersion: SpecVersion ): (serverUrl: string) => Promise { - return STATEFUL_VERSIONS.has(specVersion) - ? connectStateful - : connectStateless; + return isStatefulVersion(specVersion) ? connectStateful : connectStateless; } diff --git a/src/connection/stateless.ts b/src/connection/stateless.ts index 3b1d7d0..6949e69 100644 --- a/src/connection/stateless.ts +++ b/src/connection/stateless.ts @@ -98,7 +98,10 @@ async function readFinalSseMessage( response: Response, id: number, sink: JSONRPCNotification[] -): Promise<{ result?: unknown; error?: { code: number; message: string } }> { +): Promise<{ + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}> { const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buf = ''; diff --git a/src/index.ts b/src/index.ts index 8754a0b..ad34ffa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -254,7 +254,12 @@ program // If no command provided, run in interactive mode if (!validated.command) { - await runInteractiveMode(validated.scenario, verbose, outputDir); + await runInteractiveMode( + validated.scenario, + verbose, + outputDir, + specVersionFilter + ); process.exit(0); } diff --git a/src/mock-server/index.ts b/src/mock-server/index.ts index cdbbec1..c3e2405 100644 --- a/src/mock-server/index.ts +++ b/src/mock-server/index.ts @@ -9,7 +9,6 @@ * This is the client-conformance mirror of `Connection` in `../connection`. */ -import type express from 'express'; import type { SpecVersion } from '../types'; import type { JSONRPCRequest } from '../spec-types/2025-11-25'; @@ -26,20 +25,6 @@ export type RequestHandlers = Record< ) => unknown | Promise >; -export interface MockServerOptions { - /** - * Capabilities advertised in InitializeResult / server/discover. Defaults to - * `{ tools: {} }`. - */ - capabilities?: Record; - /** - * Hook to add routes/middleware to the underlying express app before the - * `/mcp` route is registered. Auth scenarios use this for the PRM endpoint - * and bearer-auth middleware. - */ - configure?: (app: express.Application) => void; -} - export interface MockServer { /** Full URL of the `/mcp` endpoint. */ url: string; @@ -65,12 +50,9 @@ export interface ScenarioContext { * lifecycle itself (initialize, SSE-retry) bypass this and build a raw * `http.createServer`. */ - createServer( - handlers: RequestHandlers, - opts?: MockServerOptions - ): Promise; + createServer(handlers: RequestHandlers): Promise; } export { createServerStateful } from './stateful'; -export { createServerStateless } from './stateless'; +export { createServerStateless, validateStatelessRequest } from './stateless'; export { createServerFor } from './select'; diff --git a/src/mock-server/mock-server.test.ts b/src/mock-server/mock-server.test.ts index 63274d2..cf20cc8 100644 --- a/src/mock-server/mock-server.test.ts +++ b/src/mock-server/mock-server.test.ts @@ -122,6 +122,30 @@ describe('createServerStateless', () => { }); describe('createServerStateful', () => { + async function postInit(url: string) { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 't', version: '1' } + } + }) + }); + return { + status: r.status, + contentType: r.headers.get('content-type') ?? '' + }; + } + it('accepts initialize and routes to handlers, recording non-preamble', async () => { const srv = await createServerStateful({ 'tools/list': () => ({ tools: [] }) @@ -145,4 +169,46 @@ describe('createServerStateful', () => { await srv.close(); } }); + + it('derives capabilities from handler keys; non-tools handler does not 500 initialize', async () => { + const srv = await createServerStateful({ + 'prompts/list': () => ({ prompts: [] }) + }); + try { + const { status, contentType } = await postInit(srv.url); + expect(status).toBe(200); + expect(contentType).not.toContain('text/html'); + } finally { + await srv.close(); + } + }); + + it('records requests for unregistered methods (parity with stateless)', async () => { + const srv = await createServerStateful({ + 'tools/list': () => ({ tools: [] }) + }); + try { + const { Client } = + await import('@modelcontextprotocol/sdk/client/index.js'); + const { StreamableHTTPClientTransport } = + await import('@modelcontextprotocol/sdk/client/streamableHttp.js'); + const { ResultSchema } = + await import('@modelcontextprotocol/sdk/types.js'); + const client = new Client( + { name: 't', version: '1' }, + { capabilities: {} } + ); + await client.connect(new StreamableHTTPClientTransport(new URL(srv.url))); + await client + .request( + { method: 'tools/call', params: { name: 'nope' } }, + ResultSchema + ) + .catch(() => {}); + await client.close(); + expect(srv.recorded.map((r) => r.method)).toContain('tools/call'); + } finally { + await srv.close(); + } + }); }); diff --git a/src/mock-server/select.ts b/src/mock-server/select.ts index 201ffeb..0145786 100644 --- a/src/mock-server/select.ts +++ b/src/mock-server/select.ts @@ -1,26 +1,13 @@ import type { SpecVersion } from '../types'; -import type { MockServer, MockServerOptions, RequestHandlers } from './index'; +import type { MockServer, RequestHandlers } from './index'; +import { isStatefulVersion } from '../connection/select'; import { createServerStateful } from './stateful'; import { createServerStateless } from './stateless'; -/** - * Spec versions that use the stateful lifecycle (initialize handshake). - * Kept identical to `connection/select.ts`. - */ -const STATEFUL_VERSIONS: ReadonlySet = new Set([ - '2024-11-05', - '2025-03-26', - '2025-06-18', - '2025-11-25' -]); - export function createServerFor( specVersion: SpecVersion -): ( - handlers: RequestHandlers, - opts?: MockServerOptions -) => Promise { - return STATEFUL_VERSIONS.has(specVersion) +): (handlers: RequestHandlers) => Promise { + return isStatefulVersion(specVersion) ? createServerStateful : createServerStateless; } diff --git a/src/mock-server/stateful.ts b/src/mock-server/stateful.ts index 469cff1..5004281 100644 --- a/src/mock-server/stateful.ts +++ b/src/mock-server/stateful.ts @@ -13,14 +13,37 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { JSONRPCRequest } from '../spec-types/2025-11-25'; -import type { MockServer, MockServerOptions, RequestHandlers } from './index'; +import type { MockServer, RequestHandlers } from './index'; + +const CAPABILITY_BY_PREFIX: Record = { + tools: 'tools', + prompts: 'prompts', + resources: 'resources', + completion: 'completions', + logging: 'logging' +}; + +/** + * Derive the server `capabilities` object from the registered handler method + * names so the SDK's `assertRequestHandlerCapability` gate is always satisfied. + * Shared with the stateless impl for `server/discover`. + */ +export function capabilitiesFromHandlers( + handlers: RequestHandlers +): Record { + const out: Record = {}; + for (const method of Object.keys(handlers)) { + const cap = CAPABILITY_BY_PREFIX[method.split('/')[0]]; + if (cap) out[cap] = {}; + } + return out; +} export async function createServerStateful( - handlers: RequestHandlers, - opts: MockServerOptions = {} + handlers: RequestHandlers ): Promise { const recorded: JSONRPCRequest[] = []; - const capabilities = opts.capabilities ?? { tools: {} }; + const capabilities = capabilitiesFromHandlers(handlers); // Fresh SDK Server per HTTP request (the SDK transport is single-shot in // sessionless mode after GHSA-345p-7cg4-v4c7). @@ -37,7 +60,6 @@ export async function createServerStateful( params: z.unknown().optional() }); server.setRequestHandler(schema, async (request) => { - recorded.push(request as JSONRPCRequest); try { return (await handler( (request.params ?? {}) as Record, @@ -57,14 +79,24 @@ export async function createServerStateful( const app = express(); app.use(express.json()); - opts.configure?.(app); app.post('/mcp', async (req, res) => { - const server = newServer(); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }); + // Record every JSON-RPC request the client sends (excluding the lifecycle + // preamble) at the HTTP layer so unregistered methods are captured too, + // matching the stateless impl and the MockServer.recorded contract. + const body = req.body; + if ( + body?.method && + body.method !== 'initialize' && + body.method !== 'notifications/initialized' + ) { + recorded.push(body as JSONRPCRequest); + } try { + const server = newServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); await server.connect(transport); await transport.handleRequest(req, res, req.body); res.on('close', () => { diff --git a/src/mock-server/stateless.ts b/src/mock-server/stateless.ts index d3882a6..75e1370 100644 --- a/src/mock-server/stateless.ts +++ b/src/mock-server/stateless.ts @@ -10,7 +10,8 @@ import express from 'express'; import { DRAFT_PROTOCOL_VERSION } from '../types'; import type { JSONRPCRequest } from '../spec-types/2025-11-25'; -import type { MockServer, MockServerOptions, RequestHandlers } from './index'; +import type { MockServer, RequestHandlers } from './index'; +import { capabilitiesFromHandlers } from './stateful'; const META_KEYS = [ 'io.modelcontextprotocol/protocolVersion', @@ -18,49 +19,82 @@ const META_KEYS = [ 'io.modelcontextprotocol/clientCapabilities' ] as const; -export async function createServerStateless( - handlers: RequestHandlers, - opts: MockServerOptions = {} -): Promise { - const recorded: JSONRPCRequest[] = []; - const capabilities = opts.capabilities ?? { tools: {} }; - - const app = express(); - app.use(express.json()); - opts.configure?.(app); +type IncomingHeaders = Record; - app.post('/mcp', async (req, res) => { - const body = req.body ?? {}; - const id = body.id ?? null; - const method: string = body.method; - const params = (body.params ?? {}) as Record; - const meta = params._meta as Record | undefined; +export type StatelessValidation = + | { + ok: true; + id: string | number | null; + method: string; + params: Record; + } + | { ok: false; status: number; body: object }; - const error = (status: number, code: number, message: string) => - res.status(status).json({ jsonrpc: '2.0', id, error: { code, message } }); +/** + * Shared SEP-2575 request validation: header presence, `_meta` 3-key check, + * header/`_meta` version match, version-supported check, and `server/discover` + * handling. Consumers write `res.status(v.status).json(v.body)` for the + * not-ok branch (which also covers discover) and route on the ok branch. + * + * Exported so any mock server that needs a stateless `/mcp` route (e.g. + * `auth/helpers/createServer.ts`) uses the same validation as this module. + */ +export function validateStatelessRequest( + req: { headers: IncomingHeaders; body: unknown }, + capabilities: Record +): StatelessValidation { + const body = (req.body ?? {}) as Record; + const id = (body.id ?? null) as string | number | null; + const method = body.method as string; + const params = (body.params ?? {}) as Record; + const meta = params._meta as Record | undefined; - const headerVersion = req.headers['mcp-protocol-version']; - if (!headerVersion) { - return error(400, -32001, 'Missing MCP-Protocol-Version header'); - } - const missing = META_KEYS.filter((k) => meta?.[k] === undefined); - if (missing.length > 0) { - return error( - 400, - -32602, - `Invalid params: missing _meta keys: ${missing.join(', ')}` - ); - } - if (meta?.[META_KEYS[0]] !== headerVersion) { - return error( - 400, - -32001, - 'MCP-Protocol-Version header does not match _meta.protocolVersion' - ); - } + const reject = (status: number, code: number, message: string) => + ({ + ok: false, + status, + body: { jsonrpc: '2.0', id, error: { code, message } } + }) as const; - if (method === 'server/discover') { - return res.json({ + const headerVersion = req.headers['mcp-protocol-version']; + if (!headerVersion) { + return reject(400, -32001, 'Missing MCP-Protocol-Version header'); + } + const missing = META_KEYS.filter((k) => meta?.[k] === undefined); + if (missing.length > 0) { + return reject( + 400, + -32602, + `Invalid params: missing _meta keys: ${missing.join(', ')}` + ); + } + if (meta?.[META_KEYS[0]] !== headerVersion) { + return reject( + 400, + -32001, + 'MCP-Protocol-Version header does not match _meta.protocolVersion' + ); + } + if (headerVersion !== DRAFT_PROTOCOL_VERSION) { + return { + ok: false, + status: 400, + body: { + jsonrpc: '2.0', + id, + error: { + code: -32004, + message: 'Unsupported protocol version', + data: { supported: [DRAFT_PROTOCOL_VERSION] } + } + } + }; + } + if (method === 'server/discover') { + return { + ok: false, + status: 200, + body: { jsonrpc: '2.0', id, result: { @@ -68,17 +102,38 @@ export async function createServerStateless( capabilities, serverInfo: { name: 'conformance-mock-server', version: '1.0.0' } } - }); + } + }; + } + return { ok: true, id, method, params }; +} + +export async function createServerStateless( + handlers: RequestHandlers +): Promise { + const recorded: JSONRPCRequest[] = []; + const capabilities = capabilitiesFromHandlers(handlers); + + const app = express(); + app.use(express.json()); + + app.post('/mcp', async (req, res) => { + const v = validateStatelessRequest(req, capabilities); + if (!v.ok) { + return res.status(v.status).json(v.body); } + const { id, method, params } = v; + const error = (status: number, code: number, message: string) => + res.status(status).json({ jsonrpc: '2.0', id, error: { code, message } }); - recorded.push(body as JSONRPCRequest); + recorded.push(req.body as JSONRPCRequest); const handler = handlers[method]; if (!handler) { return error(404, -32601, `Method not found: ${method}`); } try { - const result = await handler(params, body as JSONRPCRequest); + const result = await handler(params, req.body as JSONRPCRequest); return res.json({ jsonrpc: '2.0', id, result }); } catch (e) { return error(500, -32603, e instanceof Error ? e.message : String(e)); diff --git a/src/mock-server/testing.ts b/src/mock-server/testing.ts index 464fca9..1e307b8 100644 --- a/src/mock-server/testing.ts +++ b/src/mock-server/testing.ts @@ -12,7 +12,6 @@ export function testScenarioContext( ): ScenarioContext { return { specVersion, - createServer: (handlers, opts) => - createServerFor(specVersion)(handlers, opts) + createServer: (handlers) => createServerFor(specVersion)(handlers) }; } diff --git a/src/runner/client.ts b/src/runner/client.ts index f2b2e5f..b868349 100644 --- a/src/runner/client.ts +++ b/src/runner/client.ts @@ -36,9 +36,7 @@ async function executeClient( // 3. Semantic separation: scenario identifies "which test", context provides "test data" const env = { ...process.env }; env.MCP_CONFORMANCE_SCENARIO = scenarioName; - if (specVersion) { - env.MCP_CONFORMANCE_PROTOCOL_VERSION = specVersion; - } + env.MCP_CONFORMANCE_PROTOCOL_VERSION = specVersion ?? LATEST_SPEC_VERSION; if (context) { // Include scenario name in context for discriminated union parsing env.MCP_CONFORMANCE_CONTEXT = JSON.stringify({ @@ -118,8 +116,7 @@ export async function runConformanceTest( const resolvedVersion = specVersion ?? LATEST_SPEC_VERSION; const ctx: ScenarioContext = { specVersion: resolvedVersion, - createServer: (handlers, opts) => - createServerFor(resolvedVersion)(handlers, opts) + createServer: (handlers) => createServerFor(resolvedVersion)(handlers) }; console.error(`Starting scenario: ${scenarioName}`); @@ -137,7 +134,7 @@ export async function runConformanceTest( urls.serverUrl, timeout, urls.context, - specVersion + resolvedVersion ); // Print stdout/stderr if client exited with nonzero code @@ -277,7 +274,8 @@ export function printClientResults( export async function runInteractiveMode( scenarioName: string, verbose: boolean = false, - outputDir?: string + outputDir?: string, + specVersion?: SpecVersion ): Promise { let resultDir: string | undefined; @@ -289,10 +287,10 @@ export async function runInteractiveMode( // Scenario is guaranteed to exist by CLI validation const scenario = getScenario(scenarioName)!; + const resolvedVersion = specVersion ?? LATEST_SPEC_VERSION; const ctx: ScenarioContext = { - specVersion: LATEST_SPEC_VERSION, - createServer: (handlers, opts) => - createServerFor(LATEST_SPEC_VERSION)(handlers, opts) + specVersion: resolvedVersion, + createServer: (handlers) => createServerFor(resolvedVersion)(handlers) }; console.log(`Starting scenario: ${scenarioName}`); diff --git a/src/scenarios/client/auth/helpers/createServer.ts b/src/scenarios/client/auth/helpers/createServer.ts index e5681cd..e18fcdd 100644 --- a/src/scenarios/client/auth/helpers/createServer.ts +++ b/src/scenarios/client/auth/helpers/createServer.ts @@ -10,8 +10,11 @@ import { import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js'; import express, { Request, Response, NextFunction } from 'express'; import type { ConformanceCheck } from '../../../../types'; -import { DRAFT_PROTOCOL_VERSION } from '../../../../types'; -import type { ScenarioContext } from '../../../../mock-server'; +import { + validateStatelessRequest, + type ScenarioContext +} from '../../../../mock-server'; +import { isStatefulVersion } from '../../../../connection/select'; import { createRequestLogger } from '../../../request-logger'; import { MockTokenVerifier } from './mockTokenVerifier'; import { SpecReferences } from '../spec-references'; @@ -160,7 +163,7 @@ export function createServer( authMiddleware(req, res, async (err?: any) => { if (err) return next(err); - if (ctx.specVersion === DRAFT_PROTOCOL_VERSION) { + if (!isStatefulVersion(ctx.specVersion)) { return handleStateless(req, res); } const server = createMcpServer(); @@ -192,44 +195,17 @@ export function createServer( }); }); - // Stateless lifecycle for the /mcp route: validate _meta + header, serve - // server/discover, route the same tools handlers as createMcpServer. - // The bearer-auth middleware and PRM route above are version-independent. + // Stateless lifecycle for the /mcp route: shared SEP-2575 validation + + // server/discover from mock-server/stateless, then the same tools handlers + // as createMcpServer. Bearer-auth middleware and PRM route above are + // version-independent. function handleStateless(req: Request, res: Response) { - const body = req.body ?? {}; - const id = body.id ?? null; - const meta = body.params?._meta; - const hv = req.headers['mcp-protocol-version']; - if (!hv) { - return res.status(400).json({ - jsonrpc: '2.0', - id, - error: { code: -32001, message: 'Missing MCP-Protocol-Version header' } - }); - } - if ( - !meta?.['io.modelcontextprotocol/protocolVersion'] || - !meta?.['io.modelcontextprotocol/clientInfo'] || - !meta?.['io.modelcontextprotocol/clientCapabilities'] - ) { - return res.status(400).json({ - jsonrpc: '2.0', - id, - error: { code: -32602, message: 'Invalid params: missing _meta keys' } - }); - } - if (body.method === 'server/discover') { - return res.json({ - jsonrpc: '2.0', - id, - result: { - supportedVersions: [DRAFT_PROTOCOL_VERSION], - capabilities: { tools: {} }, - serverInfo: { name: 'auth-prm-pathbased-server', version: '1.0.0' } - } - }); + const v = validateStatelessRequest(req, { tools: {} }); + if (!v.ok) { + return res.status(v.status).json(v.body); } - if (body.method === 'tools/list') { + const { id, method } = v; + if (method === 'tools/list') { return res.json({ jsonrpc: '2.0', id, @@ -238,7 +214,7 @@ export function createServer( } }); } - if (body.method === 'tools/call') { + if (method === 'tools/call') { return res.json({ jsonrpc: '2.0', id,