Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
.idea/
.claude/settings.local.json
.sdk-under-test/
.sync-schema-tmp/
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
# repo's `prettier --check .` would reformat the file and fight the generator's
# output (and the refresh workflow's `git diff` check).
src/seps/traceability.json

# Vendored verbatim from modelcontextprotocol/schema/{version}/schema.ts via
# `npm run sync-schema`. Keep byte-identical with upstream so the SOURCE pin
# is meaningful and re-syncing produces a clean diff.
src/spec-types/*.ts
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
126 changes: 126 additions & 0 deletions examples/servers/typescript/everything-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
import {
ElicitResultSchema,
ResultSchema,
ProgressNotificationSchema,
LoggingMessageNotificationSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
Expand All @@ -30,6 +33,8 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import cors from 'cors';
import { randomUUID, createHmac } from 'crypto';

Expand Down Expand Up @@ -72,6 +77,49 @@ function getMrtInputText(inputResponse: unknown, field: string): string {
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
const servers: { [sessionId: string]: McpServer } = {};

// In-memory client connected to a fully-registered McpServer. Used by the
// stateless POST handler to serve carry-forward methods (tools/call,
// resources/*, prompts/get, completion/complete) without duplicating the
// registrations. The SDK doesn't yet support a stateless server natively,
// so this bridges via the in-memory transport after a one-time initialize.
//
// A fresh server+client pair is built per request so concurrent requests
// can't observe each other's notifications.
type DispatchClient = {
client: Client;
drainNotifications: () => unknown[];
close: () => Promise<void>;
};
async function getStatelessDispatchClient(): Promise<DispatchClient> {
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
const server = createMcpServer();
await server.connect(serverT);
const client = new Client(
{ name: 'stateless-dispatch', version: '1.0.0' },
{ capabilities: { sampling: {}, elicitation: {} } }
);
await client.connect(clientT);

// Buffer notifications so the stateless handler can flush them to the SSE
// response after the request completes. The SDK pre-registers a handler for
// notifications/progress so a fallback alone would miss it.
const buffer: unknown[] = [];
const collect = async (n: unknown) =>
void buffer.push({ jsonrpc: '2.0', ...(n as object) });
client.setNotificationHandler(ProgressNotificationSchema, collect);
client.setNotificationHandler(LoggingMessageNotificationSchema, collect);
client.fallbackNotificationHandler = collect;

return {
client,
drainNotifications: () => buffer.splice(0, buffer.length),
close: async () => {
await client.close();
await server.close();
}
};
}

// In-memory event store for SEP-1699 resumability
const eventStoreData = new Map<
string,
Expand Down Expand Up @@ -1304,11 +1352,19 @@ app.post('/mcp', async (req, res) => {
}

if (method === 'tools/list') {
const dispatch = await getStatelessDispatchClient();
const fromServer = (await dispatch.client.request(
{ method: 'tools/list', params: {} },
ResultSchema as any
)) as { tools: any[]; [k: string]: unknown };
await dispatch.close();
return res.json({
jsonrpc: '2.0',
id,
result: {
...fromServer,
tools: [
...fromServer.tools,
{
name: 'test_missing_capability',
description: 'Test tool requiring sampling',
Expand Down Expand Up @@ -1378,11 +1434,19 @@ app.post('/mcp', async (req, res) => {

// Mock fallbacks to answer prompts capability matches safely
if (method === 'prompts/list') {
const dispatch = await getStatelessDispatchClient();
const fromServer = (await dispatch.client.request(
{ method: 'prompts/list', params: {} },
ResultSchema as any
)) as { prompts: any[]; [k: string]: unknown };
await dispatch.close();
return res.json({
jsonrpc: '2.0',
id,
result: {
...fromServer,
prompts: [
...fromServer.prompts,
{
name: 'test_input_required_result_prompt',
description: 'MRTR: prompt that requires elicitation input'
Expand Down Expand Up @@ -1994,6 +2058,68 @@ app.post('/mcp', async (req, res) => {
}
}

// Carry-forward methods that fell through the MRTR-specific handlers above
// (tools/call for non-MRTR tools, resources/*, prompts/get for non-MRTR
// prompts, completion/complete) are dispatched to the same McpServer the
// stateful path uses, via an in-memory client. This avoids duplicating the
// tool/resource/prompt registrations for the stateless path.
//
// tools/call is served as text/event-stream so progress and logging
// notifications from the underlying tool reach the conformance client.
if (method === 'tools/call') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
});
const write = (msg: unknown) =>
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
const dispatch = await getStatelessDispatchClient();
try {
const result = await dispatch.client.request(
{ method, params },
ResultSchema as any
);
for (const n of dispatch.drainNotifications()) write(n);
write({ jsonrpc: '2.0', id, result });
} catch (e: any) {
for (const n of dispatch.drainNotifications()) write(n);
write({
jsonrpc: '2.0',
id,
error: { code: e.code ?? -32603, message: e.message, data: e.data }
});
} finally {
await dispatch.close();
}
return res.end();
}
if (
[
'resources/list',
'resources/read',
'resources/templates/list',
'prompts/get',
'completion/complete'
].includes(method)
) {
const dispatch = await getStatelessDispatchClient();
try {
const result = await dispatch.client.request(
{ method, params },
ResultSchema as any
);
return res.json({ jsonrpc: '2.0', id, result });
} 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();
}
}

// Removed Methods per SEP-2575 (Changed status from 200 to 400/404 per Transport Spec)
if (
[
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"lint:fix_check": "npm run lint:fix && git diff --exit-code --quiet",
"tier-check": "node dist/index.js tier-check",
"traceability": "tsx src/index.ts traceability",
"sync-schema": "tsx scripts/sync-schema.ts",
"check": "npm run typecheck && npm run lint",
"typecheck": "tsgo --noEmit",
"prepack": "npm run build",
Expand Down
52 changes: 52 additions & 0 deletions scripts/sync-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env -S npx tsx
/**
* Vendor schema/{version}/schema.ts from the modelcontextprotocol spec repo
* into src/spec-types/{version}.ts at a pinned SHA.
*
* Usage: npm run sync-schema -- <sha-or-ref>
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';

const VERSIONS = ['2025-03-26', '2025-06-18', '2025-11-25', 'draft'] as const;
const SPEC_REPO =
'https://github.com/modelcontextprotocol/modelcontextprotocol.git';
const OUT_DIR = join(process.cwd(), 'src', 'spec-types');

const ref = process.argv[2];
if (!ref) {
console.error('Usage: npm run sync-schema -- <sha-or-ref>');
process.exit(1);
}

const tmp = join(process.cwd(), '.sync-schema-tmp');
rmSync(tmp, { recursive: true, force: true });
mkdirSync(tmp, { recursive: true });
mkdirSync(OUT_DIR, { recursive: true });

const git = (args: string[]) =>
execFileSync('git', args, { cwd: tmp, encoding: 'utf8' });

try {
console.log(`Fetching ${SPEC_REPO} @ ${ref} ...`);
git(['init', '-q']);
git(['remote', 'add', 'origin', SPEC_REPO]);
git(['fetch', '-q', '--depth', '1', 'origin', ref]);
git(['checkout', '-q', 'FETCH_HEAD']);
const sha = git(['rev-parse', 'HEAD']).trim();

for (const v of VERSIONS) {
copyFileSync(join(tmp, 'schema', v, 'schema.ts'), join(OUT_DIR, `${v}.ts`));
console.log(` ${v} -> src/spec-types/${v}.ts`);
}

writeFileSync(
join(OUT_DIR, 'SOURCE'),
`modelcontextprotocol@${sha}\n`,
'utf8'
);
console.log(`Pinned: modelcontextprotocol@${sha}`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
Loading
Loading