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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ dist/
.vscode/
.idea/
.claude/settings.local.json
.claude/worktrees
.sdk-under-test/
.sync-schema-tmp/
.valtown-stage/
.serve-*.ts
.env
317 changes: 315 additions & 2 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ 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 {
StepsSchema,
resolveArguments,
type Captures,
type Step
} from '../../../src/steps/index.js';
import {
auth,
extractWWWAuthenticateParams
Expand Down Expand Up @@ -219,9 +225,15 @@ async function runListToolsOnlyClient(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');
}
Expand Down Expand Up @@ -291,6 +303,237 @@ registerScenario(
runJsonSchema2020_12PreservationClient
);

// ============================================================================
// Stateless gauntlet — a hand-rolled DRAFT (SEP-2575) client. No initialize,
// no session: every request carries the protocol version, client identity,
// and capabilities itself, plus the Mcp-Method/Mcp-Name routing headers
// (SEP-2243). MRTR (SEP-2322) retries echo requestState unchanged.
// The server judges each request on its own content; any isError result or
// HTTP error carries an explanation of what the client got wrong.
// ============================================================================

const DRAFT_VERSION = '2026-07-28';
const DRAFT_META = {
'io.modelcontextprotocol/protocolVersion': DRAFT_VERSION,
'io.modelcontextprotocol/clientInfo': {
name: 'everything-client',
version: '1.0.0'
},
'io.modelcontextprotocol/clientCapabilities': { elicitation: {} }
};

async function draftRpc(
serverUrl: string,
method: string,
params: Record<string, unknown> = {}
): Promise<Record<string, unknown>> {
const headers: Record<string, string> = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': DRAFT_VERSION,
'mcp-method': method
};
if (method === 'tools/call' && typeof params.name === 'string') {
headers['mcp-name'] = params.name;
}
const res = await fetch(serverUrl, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params: { ...params, _meta: DRAFT_META }
})
});
if (!res.ok) {
throw new Error(`${method}: HTTP ${res.status}: ${await res.text()}`);
}
const json = (await res.json()) as {
result?: Record<string, unknown>;
error?: { code: number; message: string };
};
if (json.error) {
throw new Error(
`${method}: JSON-RPC ${json.error.code}: ${json.error.message}`
);
}
return json.result ?? {};
}

const GAUNTLET_ARGS: Record<string, Record<string, unknown>> = {
validate_arguments: {
message: 'hello from everything-client',
count: 42,
payload: { kind: 'solid' }
},
mrtr_confirm: {},
// Listed only when a client does NOT declare elicitation; harmless to call.
elicitation_missing: {}
};

/** Answer an input_required result: accept every elicitation request. */
function answerInputRequests(
inputRequests: Record<string, { method: string }>
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(inputRequests).map(([key, request]) => {
if (request.method !== 'elicitation/create') {
throw new Error(`unsupported input request method '${request.method}'`);
}
return [key, { action: 'accept', content: { confirmed: true } }];
})
);
}

async function runGauntletClient(serverUrl: string): Promise<void> {
const discover = await draftRpc(serverUrl, 'server/discover');
logger.debug(
`server/discover: supportedVersions=${JSON.stringify(discover.supportedVersions)}`
);

const { tools } = (await draftRpc(serverUrl, 'tools/list')) as {
tools: { name: string }[];
};
logger.debug(`Gauntlet lists ${tools.length} tools`);

const failures: string[] = [];
for (const tool of tools) {
const args = GAUNTLET_ARGS[tool.name];
if (!args) {
failures.push(`no argument template for tool '${tool.name}'`);
continue;
}
let result = await draftRpc(serverUrl, 'tools/call', {
name: tool.name,
arguments: args
});
// MRTR: answer the input requests and retry with the state echoed.
if (result.resultType === 'input_required') {
result = await draftRpc(serverUrl, 'tools/call', {
name: tool.name,
inputResponses: answerInputRequests(
result.inputRequests as Record<string, { method: string }>
),
...(result.requestState !== undefined
? { requestState: result.requestState }
: {})
});
}
const content = result.content as
| { type: string; text?: string }[]
| undefined;
const text = content?.[0]?.text ?? JSON.stringify(result);
if (result.isError) {
failures.push(`${tool.name}: ${text}`);
} else {
logger.debug(`${tool.name}: ${text}`);
}
}

if (failures.length > 0) {
throw new Error(`gauntlet failures:\n ${failures.join('\n ')}`);
}
}

registerScenario('checker-2026-07-28', runGauntletClient);

// ============================================================================
// Auth-chain checker — walk the re-auth rungs in order. Each advance tool
// answers with an OAuth challenge (401 with a different resource_metadata,
// then 403 insufficient_scope); the SDK's withOAuthRetry should absorb each
// challenge, re-authorize under the new configuration, and retry.
// ============================================================================

async function runAuthChainClient(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-auth-client', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl),
handle401,
CIMD_CLIENT_METADATA_URL
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);

for (const name of [
'auth_status',
'advance_to_scoped',
'auth_status',
'advance_to_stepup',
'auth_complete'
]) {
const result = await client.callTool({ name, arguments: {} });
const text =
Array.isArray(result.content) && result.content[0]?.type === 'text'
? result.content[0].text
: JSON.stringify(result.content);
logger.debug(`${name}: ${text}`);
if (result.isError) {
throw new Error(`${name} failed: ${text}`);
}
}

await transport.close();
}

registerScenario('checker-auth', runAuthChainClient);

// The iss trap probe: calling check_iss_validation forces a re-auth whose
// authorization response carries a WRONG iss. The expected outcome is a
// client-side refusal — the call must FAIL with an iss complaint, not
// complete. Completing means the client exchanged the code anyway and the
// server's poisoned-token explanation comes back instead.
async function runAuthIssTrapProbe(serverUrl: string): Promise<void> {
const client = new Client(
{ name: 'test-auth-client', version: '1.0.0' },
{ capabilities: {} }
);
const oauthFetch = withOAuthRetry(
'test-auth-client',
new URL(serverUrl),
handle401,
CIMD_CLIENT_METADATA_URL
)(fetch);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
fetch: oauthFetch
});
await client.connect(transport);

// Two ways to learn the verdict, depending on whether the client validates
// iss. PASS: the client aborts mid-OAuth (validates iss), so callTool
// rejects locally with an iss complaint and never reaches the server.
// FAIL: the client exchanges the wrong-iss code, so the call completes with
// an in-band isError tool result carrying the FAIL verdict.
try {
const result = await client.callTool({
name: 'check_iss_validation',
arguments: {}
});
const text =
Array.isArray(result.content) && result.content[0]?.type === 'text'
? result.content[0].text
: JSON.stringify(result.content);
if (result.isError && text.includes('FAIL [check_iss_validation]')) {
throw new Error(`CAUGHT BY THE TRAP (client ignored iss): ${text}`);
}
throw new Error(`unexpected non-error result from the iss trap: ${text}`);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('CAUGHT BY THE TRAP')) throw e;
logger.debug(`iss trap outcome — client-side refusal (PASS): ${msg}`);
} finally {
await transport.close().catch(() => {});
}
}

registerScenario('checker-auth-iss', runAuthIssTrapProbe);

// ============================================================================
// request-metadata scenario (SEP-2575)
// ============================================================================
Expand Down Expand Up @@ -1177,6 +1420,68 @@ registerScenario('auth/wif-jwt-bearer', runWifJwtBearer);
// Main entry point
// ============================================================================

// ============================================================================
// Generic steering: fallback interpreter for scenarios that ship `steps`
// ============================================================================
//
// A scenario with no bespoke handler here can still be driven if the runner
// put `steps` in MCP_CONFORMANCE_CONTEXT (see src/steps). The op set is
// closed; standing defaults: connect first, accept elicitation with schema
// defaults, disconnect at the end.

function stepsFromContext(): Step[] | undefined {
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
if (!raw) return undefined;
try {
const parsed = StepsSchema.safeParse(JSON.parse(raw).steps);
return parsed.success ? parsed.data : undefined;
} catch {
return undefined;
}
}

async function runSteps(serverUrl: string, steps: Step[]): Promise<void> {
const client = new Client(
{ name: 'conformance-generic-client', version: '1.0.0' },
{ capabilities: { elicitation: { applyDefaults: true } } }
);
// Standing default: if the server asks, accept with schema defaults.
client.setRequestHandler(ElicitRequestSchema, async () => ({
action: 'accept' as const,
content: {}
}));

const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
await client.connect(transport);
logger.debug(`steps: connected, running ${steps.length} step(s)`);

const captures: Captures = {};
let connected = true;
for (const step of steps) {
logger.debug('step:', JSON.stringify(step));
switch (step.op) {
case 'tools/list':
captures['tools/list'] = await client.listTools();
break;
case 'tools/call':
captures['tools/call'] = await client.callTool({
name: step.name,
arguments: resolveArguments(captures, step.arguments)
});
break;
case 'wait':
await new Promise((r) => setTimeout(r, step.ms));
break;
case 'disconnect':
await transport.close();
connected = false;
break;
}
}
if (connected) await transport.close();
logger.debug('steps: done');
}

async function main(): Promise<void> {
const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO;
const serverUrl = process.argv[2];
Expand All @@ -1195,7 +1500,15 @@ async function main(): Promise<void> {
process.exit(1);
}

const handler = scenarioHandlers[scenarioName];
// Named handlers win; steps are the fallback for names this client has
// never heard of. MCP_CONFORMANCE_FORCE_STEPS=1 inverts that so the
// generic path can be exercised against scenarios that also have handlers.
const steps = stepsFromContext();
const named = scenarioHandlers[scenarioName];
const handler =
steps && (!named || process.env.MCP_CONFORMANCE_FORCE_STEPS === '1')
? (url: string) => runSteps(url, steps)
: named;
if (!handler) {
console.error(`Unknown scenario: ${scenarioName}`);
console.error('\nAvailable scenarios:');
Expand Down
Loading
Loading