Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
101 changes: 84 additions & 17 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
): Promise<any> {
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<void> {
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: {} }
Expand All @@ -84,14 +156,20 @@ 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
Expand All @@ -106,20 +184,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 = DRAFT_VERSION;

const sendRequestWithNegotiation = async (
method: string,
Expand Down Expand Up @@ -161,7 +228,7 @@ async function runRequestMetadataClient(serverUrl: string): Promise<void> {
);
const serverSupported: string[] =
errorResult.error.data?.supported || [];
const clientSupported = ['DRAFT-2026-v1'];
const clientSupported = [DRAFT_VERSION];
const mutuallySupported = clientSupported.filter((v) =>
serverSupported.includes(v)
);
Expand Down
8 changes: 5 additions & 3 deletions src/connection/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ const STATEFUL_VERSIONS: ReadonlySet<string> = new Set([
'2025-11-25'
]);

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

export function connectFor(
specVersion: SpecVersion
): (serverUrl: string) => Promise<Connection> {
return STATEFUL_VERSIONS.has(specVersion)
? connectStateful
: connectStateless;
return isStatefulVersion(specVersion) ? connectStateful : connectStateless;
}
5 changes: 4 additions & 1 deletion src/connection/stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
58 changes: 58 additions & 0 deletions src/mock-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* 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 { 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<string, unknown>,
request: JSONRPCRequest
) => unknown | Promise<unknown>
>;

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<void>;
}

/**
* 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): Promise<MockServer>;
}

export { createServerStateful } from './stateful';
export { createServerStateless, validateStatelessRequest } from './stateless';
export { createServerFor } from './select';
Loading
Loading