Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f60e086
feat: vendor spec schema types per-version
felixweinberger May 26, 2026
630ce5e
feat: add Connection abstraction and RunContext
felixweinberger May 26, 2026
276cfc3
refactor: thread RunContext through ClientScenario.run
felixweinberger May 26, 2026
e7c0c09
refactor: migrate server scenarios to ctx.connect() + conn.request()
felixweinberger May 26, 2026
649e81c
fix: normalize Connection error to JsonRpcError; clean up RunContext …
felixweinberger May 26, 2026
afd6278
fix(dns-rebinding): use version-appropriate probe body
felixweinberger May 26, 2026
2b8b15c
feat(everything-server): route stateless carry-forward methods to Mcp…
felixweinberger May 26, 2026
9a5dd63
refactor(connection): drop unused RequestOptions; move sdk-client; ad…
felixweinberger May 26, 2026
9c785f0
fix: address bughunt findings (response.ok check; targetVersion naming)
felixweinberger May 26, 2026
396c055
fix(sse-multiple-streams): keep scenario in draft; version-aware requ…
felixweinberger May 27, 2026
d3ba751
feat: add MockServer abstraction and ScenarioContext
felixweinberger May 26, 2026
64ad717
refactor: thread ScenarioContext through Scenario.start()
felixweinberger May 26, 2026
7add7b3
refactor: migrate tools_call to ctx.createServer; tag 2025-only clien…
felixweinberger May 26, 2026
24b164e
feat(auth): make createServer helper version-aware via ScenarioContext
felixweinberger May 26, 2026
be9a85c
feat(everything-client): pick stateless requester by MCP_CONFORMANCE_…
felixweinberger May 26, 2026
9720252
fix: address review findings on MockServer (dead opts, shared validat…
felixweinberger May 26, 2026
8459baf
fix(mock-server): record stateless requests before validation; docume…
pcarleton Jun 2, 2026
8077d8d
refactor(mock-server): tri-state result for validateStatelessRequest …
pcarleton Jun 2, 2026
3dce0e8
feat(runner): single-source the version→lifecycle mapping; export MCP…
pcarleton Jun 2, 2026
b56fbc2
fix(everything-client): list-only flow for json-schema-ref-no-deref
pcarleton Jun 2, 2026
8f91523
fix(tools_call): make getChecks() idempotent
pcarleton Jun 2, 2026
10712a5
fix: ensure dispatch and transport cleanup runs when handlers throw
pcarleton Jun 2, 2026
b74dca4
fix(everything-client): send Accept header on stateless requests
pcarleton Jun 2, 2026
1f18fec
chore: exclude .claude/ from vitest globs
pcarleton Jun 2, 2026
6334935
fix(request-metadata): use spec error code -32004 for unsupported pro…
pcarleton Jun 2, 2026
2ee17c9
fix(auth): register transport cleanup before handleRequest in createS…
pcarleton Jun 2, 2026
5be5031
fix(everything-server): return JSON-RPC errors from stateless list di…
pcarleton Jun 2, 2026
4ac3b51
Merge main into fweinberger/client-runcontext
pcarleton Jun 3, 2026
91962c0
refactor(runner): drop MCP_CONFORMANCE_LIFECYCLE; derive lifecycle fr…
pcarleton Jun 3, 2026
b9307d6
fix(everything-server): return JSON-RPC errors from stateless resourc…
pcarleton Jun 3, 2026
3e51f34
fix(everything-server): include requested field in -32004 error data
pcarleton Jun 3, 2026
e5eabab
feat(runner): client-side spec-version inference, skip, and --force
pcarleton Jun 3, 2026
a30a9c6
fix(auth): gate SEP-837 application_type check to spec versions that …
pcarleton Jun 3, 2026
22a959f
feat(sdk-runner): --spec-version passthrough with per-SDK default
pcarleton Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,13 @@ npx @modelcontextprotocol/conformance client --command "<client-command>" --scen
- `--command` - The command to run your MCP client (can include flags)
- `--scenario` - The test scenario to run (e.g., "initialize")
- `--suite` - Run a suite of tests in parallel: `all`, `core`, `extensions`, `backcompat`, `auth`, `metadata`, `draft` (scenarios targeting the in-progress draft spec), or `sep-835`
- `--spec-version <version>` - Filter scenarios by spec version (e.g., `2025-11-25`, `DRAFT-2026-v1`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios
- `--spec-version <version>` - Filter scenarios by spec version (e.g., `2025-11-25`, `DRAFT-2026-v1`; `draft` is accepted as an alias for the current draft identifier). The draft version selects the latest dated release plus any draft-only scenarios. When omitted, the version is inferred from the scenario's spec applicability (draft-only scenarios run at the draft version, everything else at the latest dated release); an explicitly requested version outside a scenario's applicability window skips the scenario (exit 0) unless `--force` is passed
- `--force` - Run a scenario even if it is not applicable at the requested `--spec-version`
- `--expected-failures <path>` - Path to YAML baseline file of known failures (see [Expected Failures](#expected-failures))
- `--timeout` - Timeout in milliseconds (default: 30000)
- `--verbose` - Show verbose output

The framework appends `<server-url>` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data. When `--spec-version` is passed, its resolved value is forwarded to the client process as `MCP_CONFORMANCE_PROTOCOL_VERSION`; example clients can use this value directly as their `protocolVersion`. SDKs that hard-code their protocol version can ignore it.
The framework appends `<server-url>` as an argument to your command and sets the `MCP_CONFORMANCE_SCENARIO` environment variable to the scenario name. For scenarios that require additional context (e.g., client credentials), the `MCP_CONFORMANCE_CONTEXT` environment variable contains a JSON object with scenario-specific data. When `--spec-version` is passed, its resolved value is forwarded to the client process as `MCP_CONFORMANCE_PROTOCOL_VERSION`; example clients can use this value directly as their `protocolVersion`. SDKs that hard-code their protocol version can ignore it. Clients under test must derive the lifecycle from the protocol version they are asked to run: dated versions through `2025-11-25` use the stateful lifecycle (initialize handshake), while the 2026 draft (`DRAFT-2026-v1`) uses the stateless lifecycle (per-request `_meta`).

### Server Testing

Expand Down Expand Up @@ -237,6 +238,11 @@ npm start -- sdk --path ../typescript-sdk --skip-build --mode client
# Narrow to one scenario / suite
npm start -- sdk --path ../typescript-sdk --mode server --scenario server-initialize
npm start -- sdk typescript-sdk --mode client --suite auth

# Target a specific spec version (passed through to the underlying run).
# When omitted, the SDK's `specVersion` from KNOWN_SDKS is used, if set —
# e.g. typescript-sdk-v1 defaults to 2025-11-25.
npm start -- sdk typescript-sdk --mode client --spec-version draft
```

Build/run commands for each official SDK are looked up by name from [`src/sdk-runner/known-sdks.ts`](src/sdk-runner/known-sdks.ts) — no config file is required in the SDK repo. Resolution order is **CLI flag > built-in entry**, so any field can be overridden on the command line for refs that diverge from the built-in.
Expand Down
152 changes: 132 additions & 20 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js';
import { STATELESS_SPEC_VERSIONS } from '../../../src/connection/select.js';
import {
auth,
extractWWWAuthenticateParams
Expand Down Expand Up @@ -70,10 +72,97 @@ 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 the runner says the resolved
// spec version is stateless, so the same handler exercises both lifecycles.
// ============================================================================

const PROTOCOL_VERSION = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION;

// Lifecycle decision: derived from the runner-provided protocol version.
// The version→lifecycle mapping is spec knowledge a client must own; this
// in-repo client imports the stateless version set from src/ so it cannot
// drift from the runner's mapping.
const USE_STATELESS_LIFECYCLE = PROTOCOL_VERSION
? (STATELESS_SPEC_VERSIONS as readonly string[]).includes(PROTOCOL_VERSION)
: false;

// Wire protocolVersion for stateless requests: the runner-resolved version
// when available (so a dated stateless release is exercised under its own
// identifier), the current draft otherwise.
const STATELESS_PROTOCOL_VERSION = PROTOCOL_VERSION ?? DRAFT_PROTOCOL_VERSION;

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<string, unknown> = {}
): Promise<any> {
const _meta = {
'io.modelcontextprotocol/protocolVersion': STATELESS_PROTOCOL_VERSION,
...STATELESS_META_BASE,
...((params._meta as object | undefined) ?? {})
};
const response = await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Servers built on the SDK's StreamableHTTPServerTransport reject
// requests that don't accept both JSON and SSE responses.
Accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': STATELESS_PROTOCOL_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<void> {
if (USE_STATELESS_LIFECYCLE) {
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: {} }
Expand All @@ -84,20 +173,52 @@ async function runBasicClient(serverUrl: string): Promise<void> {
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
// simply never fetches that URI, so the basic connect+listTools flow is the
// correct behavior here.
registerScenario('json-schema-ref-no-deref', runBasicClient);
// simply never fetches that URI. The scenario's mock only serves tools/list,
// so this handler stops after listing instead of reusing runBasicClient
// (whose tools/call would get -32601 and fail the run).
async function runListToolsOnlyClient(serverUrl: string): Promise<void> {
if (USE_STATELESS_LIFECYCLE) {
logger.debug('Stateless lifecycle: calling tools/list');
const list = await statelessRequest(serverUrl, 'tools/list');
logger.debug('Successfully listed tools:', JSON.stringify(list));
return;
}

const client = new Client(
{ name: 'test-client', version: '1.0.0' },
{ capabilities: {} }
);

const transport = new StreamableHTTPClientTransport(new URL(serverUrl));

await client.connect(transport);
logger.debug('Successfully connected to MCP server');

await client.listTools();
logger.debug('Successfully listed tools');

await transport.close();
logger.debug('Connection closed successfully');
}

registerScenario('json-schema-ref-no-deref', runListToolsOnlyClient);

// ============================================================================
// request-metadata scenario (SEP-2575)
Expand All @@ -106,20 +227,9 @@ registerScenario('json-schema-ref-no-deref', runBasicClient);
async function runRequestMetadataClient(serverUrl: string): Promise<void> {
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 = STATELESS_PROTOCOL_VERSION;

const sendRequestWithNegotiation = async (
method: string,
Expand Down Expand Up @@ -166,7 +276,9 @@ async function runRequestMetadataClient(serverUrl: string): Promise<void> {
);
const serverSupported: string[] =
errorResult.error.data?.supported || [];
const clientSupported = ['DRAFT-2026-v1'];
const clientSupported = [
...new Set([STATELESS_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION])
];
const mutuallySupported = clientSupported.filter((v) =>
serverSupported.includes(v)
);
Expand Down
29 changes: 28 additions & 1 deletion examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,7 +1283,10 @@ app.post('/mcp', async (req, res) => {
error: {
code: -32004,
message: 'UnsupportedProtocolVersionError',
data: { supported: ['DRAFT-2026-v1'] }
data: {
supported: ['DRAFT-2026-v1'],
requested: String(metaVersion)
}
}
});
}
Expand Down Expand Up @@ -1436,6 +1439,12 @@ app.post('/mcp', async (req, res) => {
cacheScope: 'public'
}
});
} catch (e: any) {
return res.json({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
Expand Down Expand Up @@ -1465,6 +1474,12 @@ app.post('/mcp', async (req, res) => {
cacheScope: 'public'
}
});
} catch (e: any) {
return res.json({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
Expand Down Expand Up @@ -1549,6 +1564,12 @@ app.post('/mcp', async (req, res) => {
cacheScope: 'public'
}
});
} catch (e: any) {
return res.json({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
Expand All @@ -1570,6 +1591,12 @@ app.post('/mcp', async (req, res) => {
cacheScope: 'public'
}
});
} catch (e: any) {
return res.json({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
Expand Down
19 changes: 18 additions & 1 deletion src/connection/connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { connectFor } from './select';
import {
connectFor,
isStatefulVersion,
STATELESS_SPEC_VERSIONS
} from './select';
import { connectStateful } from './stateful';
import { connectStateless } from './stateless';
import { JsonRpcError } from './index';
import { DRAFT_PROTOCOL_VERSION } from '../types';

describe('connectFor', () => {
it('returns stateful for dated 2025-x versions', () => {
Expand All @@ -20,6 +25,18 @@ describe('connectFor', () => {
});
});

describe('STATELESS_SPEC_VERSIONS', () => {
it('contains exactly the versions isStatefulVersion rejects', () => {
expect(STATELESS_SPEC_VERSIONS.length).toBeGreaterThan(0);
for (const v of STATELESS_SPEC_VERSIONS) {
expect(isStatefulVersion(v)).toBe(false);
}
});
it('currently contains only the draft version', () => {
expect(STATELESS_SPEC_VERSIONS).toEqual([DRAFT_PROTOCOL_VERSION]);
});
});

describe('connectStateless', () => {
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
Expand Down
28 changes: 26 additions & 2 deletions src/connection/select.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { SpecVersion } from '../types';
import {
DATED_SPEC_VERSIONS,
DRAFT_PROTOCOL_VERSION,
type SpecVersion
} from '../types';
import type { Connection } from './index';
import { connectStateful } from './stateful';
import { connectStateless } from './stateless';
Expand All @@ -14,10 +18,30 @@ const STATEFUL_VERSIONS: ReadonlySet<string> = new Set([
'2025-11-25'
]);

/** Every spec version the suite can target, in timeline order. */
const ALL_SPEC_VERSIONS: readonly SpecVersion[] = [
...DATED_SPEC_VERSIONS,
DRAFT_PROTOCOL_VERSION
];

export function isStatefulVersion(v: SpecVersion): boolean {
return STATEFUL_VERSIONS.has(v);
}

/**
* Spec versions that use the stateless lifecycle, derived from
* {@link isStatefulVersion} so there is a single source of truth for the
* version→lifecycle mapping. The list grows automatically when the draft is
* dated (added to `DATED_SPEC_VERSIONS` without joining `STATEFUL_VERSIONS`)
* or a second stateless version appears.
*/
export const STATELESS_SPEC_VERSIONS: readonly SpecVersion[] =
ALL_SPEC_VERSIONS.filter((v) => !isStatefulVersion(v));

export function connectFor(
specVersion: SpecVersion
): (serverUrl: string) => Promise<Connection> {
return STATEFUL_VERSIONS.has(specVersion)
return isStatefulVersion(specVersion)
? connectStateful
: // Pass the version through so stateless requests declare the spec
// version the run was invoked with (matters under --force).
Expand Down
Loading
Loading