diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index a9cf90c..245128b 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -726,6 +726,149 @@ registerScenario( runEnterpriseManagedAuthorization ); +// ============================================================================ +// MRTR client conformance (SEP-2322) +// ============================================================================ + +async function runMRTRClient(serverUrl: string): Promise { + let nextId = 1; + + async function sendRpc( + method: string, + params?: Record + ): Promise<{ + id: number; + result?: Record; + error?: { code: number; message: string }; + }> { + const id = nextId++; + const body: Record = { + jsonrpc: '2.0', + id, + method + }; + if (params) body.params = params; + + const resp = await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (resp.status === 204) return { id, result: {} }; + return (await resp.json()) as { + id: number; + result?: Record; + error?: { code: number; message: string }; + }; + } + + // List tools + const toolsResp = await sendRpc('tools/list'); + const tools = + (toolsResp.result as { tools: Array<{ name: string }> })?.tools ?? []; + logger.debug( + 'Available tools:', + tools.map((t) => t.name) + ); + + // Tool 1: test_mrtr_echo_state — call, get InputRequiredResult with requestState, retry + const r1 = await sendRpc('tools/call', { + name: 'test_mrtr_echo_state', + arguments: {} + }); + + const r1Result = r1.result as Record | undefined; + if (r1Result?.resultType === 'input_required') { + const inputRequests = r1Result.inputRequests as Record; + const requestState = r1Result.requestState as string | undefined; + + // Build inputResponses by fulfilling each inputRequest + const inputResponses: Record = {}; + for (const [key, req] of Object.entries(inputRequests)) { + const request = req as { method: string; params: unknown }; + if (request.method === 'elicitation/create') { + inputResponses[key] = { + action: 'accept', + content: { confirmed: true } + }; + } + } + + // Call an unrelated tool BEFORE retrying — must NOT carry over inputResponses/requestState + await sendRpc('tools/call', { + name: 'test_mrtr_unrelated', + arguments: {} + }); + logger.debug( + 'test_mrtr_unrelated: called without MRTR state (isolation check)' + ); + + // Retry with inputResponses + requestState echoed back unchanged + const retryParams: Record = { + name: 'test_mrtr_echo_state', + arguments: {}, + inputResponses + }; + if (requestState !== undefined) { + retryParams.requestState = requestState; + } + + await sendRpc('tools/call', retryParams); + logger.debug('test_mrtr_echo_state: MRTR flow completed'); + } + + // Tool 2: test_mrtr_no_state — call, get InputRequiredResult WITHOUT requestState, retry without it + const r2 = await sendRpc('tools/call', { + name: 'test_mrtr_no_state', + arguments: {} + }); + + const r2Result = r2.result as Record | undefined; + if (r2Result?.resultType === 'input_required') { + const inputRequests = r2Result.inputRequests as Record; + + // Build inputResponses + const inputResponses: Record = {}; + for (const [key, req] of Object.entries(inputRequests)) { + const request = req as { method: string; params: unknown }; + if (request.method === 'elicitation/create') { + inputResponses[key] = { + action: 'accept', + content: { confirmed: true } + }; + } + } + + // Retry WITHOUT requestState (server didn't send one) + await sendRpc('tools/call', { + name: 'test_mrtr_no_state', + arguments: {}, + inputResponses + }); + logger.debug('test_mrtr_no_state: MRTR flow completed'); + } + + // Tool 3: test_mrtr_no_result_type — returns result without resultType field + // Client must treat it as complete (default) and NOT retry + const r3 = await sendRpc('tools/call', { + name: 'test_mrtr_no_result_type', + arguments: {} + }); + + const r3Result = r3.result as Record | undefined; + if (r3Result && !r3Result.resultType) { + // No resultType means default to "complete" — do nothing, don't retry + logger.debug( + 'test_mrtr_no_result_type: result has no resultType, treating as complete' + ); + } + + logger.debug('MRTR client scenario completed'); +} + +registerScenario('sep-2322-client-request-state', runMRTRClient); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/examples/servers/typescript/everything-server.ts b/examples/servers/typescript/everything-server.ts index 5b12c61..bdfb974 100644 --- a/examples/servers/typescript/everything-server.ts +++ b/examples/servers/typescript/everything-server.ts @@ -28,12 +28,43 @@ import { import { z } from 'zod'; import { toJsonSchemaCompat } from '@modelcontextprotocol/sdk/server/zod-json-schema-compat.js'; import cors from 'cors'; -import { randomUUID } from 'crypto'; +import { randomUUID, createHmac } from 'crypto'; // Server state const resourceSubscriptions = new Set(); const watchedResourceContent = 'Watched resource content'; +// HMAC-based requestState for SEP-2322 MRTR integrity tests +const MRTR_STATE_SECRET = 'conformance-mrtr-secret-' + randomUUID(); + +function signMrtState(payload: Record): string { + const data = JSON.stringify(payload); + const hmac = createHmac('sha256', MRTR_STATE_SECRET) + .update(data) + .digest('hex'); + return JSON.stringify({ data, hmac }); +} + +function verifyMrtState(raw: string): Record | null { + try { + const { data, hmac } = JSON.parse(raw) as { data: string; hmac: string }; + const expected = createHmac('sha256', MRTR_STATE_SECRET) + .update(data) + .digest('hex'); + if (hmac !== expected) return null; + return JSON.parse(data) as Record; + } catch { + return null; + } +} + +function getMrtInputText(inputResponse: unknown, field: string): string { + const content = (inputResponse as Record | undefined) + ?.content as Record | undefined; + const value = content?.[field]; + return typeof value === 'string' ? value : 'unknown'; +} + // Session management const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; const servers: { [sessionId: string]: McpServer } = {}; @@ -1219,6 +1250,52 @@ app.post('/mcp', async (req, res) => { description: 'Test tool requiring sampling', inputSchema: { type: 'object', properties: {} } }, + { + name: 'test_input_required_result_elicitation', + description: + 'MRTR: returns InputRequiredResult with elicitation request', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_sampling', + description: + 'MRTR: returns InputRequiredResult with sampling request', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_list_roots', + description: + 'MRTR: returns InputRequiredResult with roots/list request', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_request_state', + description: + 'MRTR: returns InputRequiredResult with requestState', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_multiple_inputs', + description: + 'MRTR: returns InputRequiredResult with multiple input requests', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_multi_round', + description: 'MRTR: multi-round InputRequiredResult workflow', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_tampered_state', + description: 'MRTR: HMAC-signed requestState integrity test', + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'test_input_required_result_capabilities', + description: + 'MRTR: respects client capabilities in inputRequests', + inputSchema: { type: 'object', properties: {} } + }, { name: 'test_streaming_elicitation', description: @@ -1240,12 +1317,74 @@ app.post('/mcp', async (req, res) => { return res.json({ jsonrpc: '2.0', id, - result: { prompts: [] } + result: { + prompts: [ + { + name: 'test_input_required_result_prompt', + description: 'MRTR: prompt that requires elicitation input' + } + ] + } }); } + // SEP-2322 MRTR: prompts/get handler + if (method === 'prompts/get') { + if (params.name === 'test_input_required_result_prompt') { + const inputResponses = params.inputResponses as + | Record + | undefined; + if (inputResponses?.['user_context']) { + const context = getMrtInputText( + inputResponses['user_context'], + 'context' + ); + return res.json({ + jsonrpc: '2.0', + id, + result: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with context: ${context}` + } + } + ] + } + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + user_context: { + method: 'elicitation/create', + params: { + message: 'What context should the prompt use?', + requestedSchema: { + type: 'object', + properties: { context: { type: 'string' } }, + required: ['context'] + } + } + } + } + } + }); + } + } + if (method === 'tools/call') { const name = params.name; + const inputResponses = params.inputResponses as + | Record + | undefined; + const requestState = params.requestState as string | undefined; + if (name === 'test_missing_capability') { const clientCaps = meta['io.modelcontextprotocol/clientCapabilities']; @@ -1268,6 +1407,455 @@ app.post('/mcp', async (req, res) => { }); } + // ===== SEP-2322 MRTR tools/call handlers ===== + + if (name === 'test_input_required_result_elicitation') { + if (inputResponses?.['user_name']) { + const userName = getMrtInputText(inputResponses['user_name'], 'name'); + return res.json({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: `Hello, ${userName}!` }] } + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + } + } + }); + } + + if (name === 'test_input_required_result_sampling') { + if (inputResponses?.['sample_request']) { + const sample = inputResponses['sample_request'] as Record< + string, + unknown + >; + const content = sample.content as Record | undefined; + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}` + } + ] + } + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + sample_request: { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'What is the capital of France?' + } + } + ], + maxTokens: 100 + } + } + } + } + }); + } + + if (name === 'test_input_required_result_list_roots') { + if (inputResponses?.['roots_request']) { + const rootsResult = inputResponses['roots_request'] as Record< + string, + unknown + >; + const roots = Array.isArray(rootsResult.roots) + ? rootsResult.roots + : []; + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] + } + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + roots_request: { method: 'roots/list', params: {} } + } + } + }); + } + + if (name === 'test_input_required_result_request_state') { + if (requestState && inputResponses?.['confirm']) { + const state = JSON.parse(requestState) as Record; + const ok = (inputResponses['confirm'] as Record) + ?.content as Record | undefined; + if (state.kind === 'request-state' && ok?.ok === true) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'state-ok: requestState validated' } + ] + } + }); + } + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + } + } + }, + requestState: JSON.stringify({ + kind: 'request-state', + nonce: randomUUID() + }) + } + }); + } + + if (name === 'test_input_required_result_multiple_inputs') { + if ( + requestState && + inputResponses?.['user_name'] && + inputResponses['greeting'] && + inputResponses['client_roots'] + ) { + const state = JSON.parse(requestState) as Record; + if (state.kind === 'multiple-inputs') { + const userName = getMrtInputText( + inputResponses['user_name'], + 'name' + ); + const greetingContent = ( + inputResponses['greeting'] as Record + ).content as Record | undefined; + const greeting = + typeof greetingContent?.text === 'string' + ? greetingContent.text + : 'Hello there!'; + const rootsResult = inputResponses['client_roots'] as Record< + string, + unknown + >; + const roots = Array.isArray(rootsResult.roots) + ? rootsResult.roots + : []; + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Name: ${userName}; Greeting: ${greeting}; Roots: ${roots.length}` + } + ] + } + }); + } + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + }, + greeting: { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { type: 'text', text: 'Generate a greeting' } + } + ], + maxTokens: 50 + } + }, + client_roots: { method: 'roots/list', params: {} } + }, + requestState: JSON.stringify({ + kind: 'multiple-inputs', + nonce: randomUUID() + }) + } + }); + } + + if (name === 'test_input_required_result_multi_round') { + if (!requestState) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + step1: { + method: 'elicitation/create', + params: { + message: 'Step 1: What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + }, + requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) + } + }); + } + const state = JSON.parse(requestState) as Record; + if (state.round === 1 && inputResponses?.['step1']) { + const userName = getMrtInputText(inputResponses['step1'], 'name'); + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + step2: { + method: 'elicitation/create', + params: { + message: 'Step 2: What is your favorite color?', + requestedSchema: { + type: 'object', + properties: { color: { type: 'string' } }, + required: ['color'] + } + } + } + }, + requestState: JSON.stringify({ + round: 2, + name: userName, + nonce: randomUUID() + }) + } + }); + } + if (state.round === 2 && inputResponses?.['step2']) { + const userName = + typeof state.name === 'string' ? state.name : 'friend'; + const color = getMrtInputText(inputResponses['step2'], 'color'); + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `Multi-round complete for ${userName} who likes ${color}` + } + ] + } + }); + } + // Fallback: restart + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + step1: { + method: 'elicitation/create', + params: { + message: 'Step 1: What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + }, + requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) + } + }); + } + + if (name === 'test_input_required_result_tampered_state') { + if (requestState) { + const verified = verifyMrtState(requestState); + if (!verified) { + return res.json({ + jsonrpc: '2.0', + id, + error: { + code: -32602, + message: 'requestState integrity check failed' + } + }); + } + if (verified.kind === 'tamper-test' && inputResponses?.['confirm']) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'integrity-ok: state verified' } + ] + } + }); + } + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + } + } + }, + requestState: signMrtState({ + kind: 'tamper-test', + nonce: randomUUID() + }) + } + }); + } + + if (name === 'test_input_required_result_capabilities') { + const clientCaps = meta[ + 'io.modelcontextprotocol/clientCapabilities' + ] as Record | undefined; + const inputRequests: Record = {}; + + if (clientCaps?.elicitation) { + inputRequests['elicit_input'] = { + method: 'elicitation/create', + params: { + message: 'Elicitation input', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'] + } + } + }; + } + if (clientCaps?.sampling) { + inputRequests['sample_input'] = { + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { type: 'text', text: 'Sample request' } + } + ], + maxTokens: 50 + } + }; + } + + if (inputResponses && Object.keys(inputResponses).length > 0) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: `capabilities-ok: received ${Object.keys(inputResponses).join(',')}` + } + ] + } + }); + } + if (Object.keys(inputRequests).length === 0) { + return res.json({ + jsonrpc: '2.0', + id, + result: { + content: [ + { type: 'text', text: 'No supported capabilities declared' } + ] + } + }); + } + return res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests, + requestState: signMrtState({ + kind: 'capabilities-test', + nonce: randomUUID() + }) + } + }); + } + // Progressive IncompleteResult Stream Generator Handling if (name === 'test_streaming_elicitation') { res.writeHead(200, { diff --git a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts new file mode 100644 index 0000000..232e794 --- /dev/null +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env node + +/** + * SEP-2322 MRTR Broken Server — Negative Test Case + * + * Deliberately violates several SEP-2322 MUST requirements: + * 1. Omits `resultType` field from InputRequiredResult responses + * 2. Returns InputRequiredResult on `tools/list` (unsupported method) + * 3. Accepts tampered requestState without integrity verification + * + * The conformance scenarios should emit FAILURE against this server. + */ + +import express from 'express'; +import { randomUUID } from 'crypto'; + +const PORT = parseInt(process.env.PORT || '3011', 10); + +// --- JSON-RPC dispatch --- + +type Handler = (params: Record) => unknown; + +const handlers: Record = {}; + +handlers['server/discover'] = () => ({ + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { + tools: {}, + prompts: {}, + elicitation: {} + }, + serverInfo: { name: 'sep-2322-mrtr-broken-server', version: '1.0.0' } +}); + +// BUG 2: Returns InputRequiredResult on tools/list (unsupported method) +handlers['tools/list'] = () => ({ + resultType: 'input_required', + inputRequests: { + bogus: { + method: 'elicitation/create', + params: { + message: 'This should not happen on tools/list', + requestedSchema: { type: 'object', properties: {} } + } + } + }, + tools: [ + { + name: 'test_input_required_result_elicitation', + description: 'Test tool for elicitation', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_tampered_state', + description: 'Test tool for tampered state', + inputSchema: { type: 'object' as const, properties: {} } + } + ] +}); + +handlers['prompts/list'] = () => ({ + prompts: [] +}); + +handlers['tools/call'] = (params) => { + const toolName = params.name as string; + const inputResponses = params.inputResponses as + | Record + | undefined; + + switch (toolName) { + case 'test_input_required_result_elicitation': { + if (inputResponses?.['user_name']) { + return { + content: [{ type: 'text', text: 'Hello!' }] + }; + } + // BUG 1: Omits `resultType` field — spec says MUST include it + return { + // resultType: 'input_required', <-- deliberately omitted + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + } + } + } + }; + } + + case 'test_input_required_result_tampered_state': { + if (inputResponses) { + // BUG 3: Accepts ANY requestState without verification + // A compliant server MUST reject tampered state + return { + content: [{ type: 'text', text: 'Accepted (no integrity check)' }] + }; + } + return { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Confirm?', + requestedSchema: { + type: 'object', + properties: { ok: { type: 'boolean' } }, + required: ['ok'] + } + } + } + }, + requestState: 'unprotected-state-' + randomUUID() + }; + } + + default: + throw { code: -32602, message: `Unknown tool: ${toolName}` }; + } +}; + +// --- Express app --- + +const app = express(); +app.use(express.json()); + +app.post('/mcp', (req, res) => { + const body = req.body as { + jsonrpc: string; + id: number; + method: string; + params?: Record; + }; + + const handler = handlers[body.method]; + if (!handler) { + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { code: -32601, message: `Method not found: ${body.method}` } + }); + return; + } + + try { + const result = handler(body.params || {}); + res.json({ jsonrpc: '2.0', id: body.id, result }); + } catch (err: unknown) { + const error = err as { code?: number; message?: string }; + res.json({ + jsonrpc: '2.0', + id: body.id, + error: { + code: error.code || -32603, + message: error.message || 'Internal error' + } + }); + } +}); + +app.listen(PORT, () => { + console.log( + `sep-2322-mrtr-broken-server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/src/scenarios/client/mrtr-client.test.ts b/src/scenarios/client/mrtr-client.test.ts new file mode 100644 index 0000000..46268aa --- /dev/null +++ b/src/scenarios/client/mrtr-client.test.ts @@ -0,0 +1,41 @@ +/** + * Integration test for MRTR client conformance scenario (SEP-2322). + * + * Runs the everything-client's MRTR handler in-process against the scenario server + * and verifies all checks pass. + */ +import { describe, test, expect } from 'vitest'; +import { + runClientAgainstScenario, + InlineClientRunner +} from './auth/test_helpers/testClient'; +import { getHandler } from '../../../examples/clients/typescript/everything-client'; +import { getScenario } from '../index'; + +describe('MRTR client scenario (SEP-2322)', () => { + test('everything-client passes sep-2322-client-request-state scenario', async () => { + const clientFn = getHandler('sep-2322-client-request-state'); + if (!clientFn) { + throw new Error( + 'No handler registered for scenario: sep-2322-client-request-state' + ); + } + + const scenario = getScenario('sep-2322-client-request-state'); + if (!scenario) { + throw new Error('Scenario not found: sep-2322-client-request-state'); + } + + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, 'sep-2322-client-request-state'); + + const checks = scenario.getChecks(); + + for (const check of checks) { + expect( + check.status, + `Check "${check.id}" failed: ${check.errorMessage ?? ''}` + ).toBe('SUCCESS'); + } + }); +}); diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts new file mode 100644 index 0000000..431fafe --- /dev/null +++ b/src/scenarios/client/mrtr-client.ts @@ -0,0 +1,486 @@ +/** + * SEP-2322: MRTR Client Conformance Tests + * + * Tests that clients correctly handle the MRTR (Multi-Round Tool Resolution) flow: + * - Echo requestState back unchanged when retrying + * - Don't include requestState when server didn't send one + * - Use a different JSON-RPC id on retry + * + * The server exposes two tools. The client calls each tool, gets InputRequiredResult, + * fulfills the elicitation, and retries. The server verifies correct client behavior. + */ + +import type { Scenario, ConformanceCheck } from '../../types'; +import { DRAFT_PROTOCOL_VERSION, ScenarioUrls } from '../../types'; +import express, { Request, Response } from 'express'; +import { randomUUID } from 'crypto'; + +const MRTR_SPEC_REFERENCES = [ + { + id: 'SEP-2322-MRTR', + url: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr' + } +]; + +const TOOLS = [ + { + name: 'test_mrtr_echo_state', + description: + 'Test tool: triggers MRTR flow with requestState. Client must echo state back unchanged.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, + { + name: 'test_mrtr_no_state', + description: + 'Test tool: triggers MRTR flow WITHOUT requestState. Client must NOT include requestState in retry.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, + { + name: 'test_mrtr_unrelated', + description: + 'Test tool: simple tool called between MRTR rounds. Must NOT carry inputResponses or requestState from another tool.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, + { + name: 'test_mrtr_no_result_type', + description: + 'Test tool: returns a result without resultType. Client must treat it as complete (default).', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + } +]; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: Record; +} + +function createMRTRServer(checks: ConformanceCheck[]): express.Application { + const app = express(); + app.use(express.json()); + + // Track original JSON-RPC ids per tool to verify they change on retry + const originalIds = new Map(); + // Track the exact requestState string sent, to verify byte-exact echo on retry + const sentStates = new Map(); + + app.post('/mcp', (req: Request, res: Response) => { + const body = req.body as JsonRpcRequest; + const { id, method, params } = body; + + switch (method) { + case 'notifications/initialized': { + res.status(204).end(); + return; + } + + case 'tools/list': { + res.json({ + jsonrpc: '2.0', + id, + result: { tools: TOOLS } + }); + return; + } + + case 'tools/call': { + const toolName = (params as Record)?.name as string; + const inputResponses = (params as Record) + ?.inputResponses as Record | undefined; + const requestState = (params as Record) + ?.requestState as string | undefined; + + if (toolName === 'test_mrtr_echo_state') { + handleEchoState(id, inputResponses, requestState, checks, res); + return; + } + + if (toolName === 'test_mrtr_no_state') { + handleNoState(id, inputResponses, requestState, checks, res); + return; + } + + if (toolName === 'test_mrtr_unrelated') { + handleUnrelated(inputResponses, requestState, checks, res, id); + return; + } + + if (toolName === 'test_mrtr_no_result_type') { + handleNoResultType(id, inputResponses, checks, res); + return; + } + + res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Unknown tool: ${toolName}` } + }); + return; + } + + case 'elicitation/create': { + // Client is fulfilling our ElicitRequest — accept it + res.json({ + jsonrpc: '2.0', + id, + result: { action: 'accept', content: { confirmed: true } } + }); + return; + } + + default: { + res.json({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` } + }); + return; + } + } + }); + + function handleEchoState( + id: string | number, + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response + ) { + if (!inputResponses) { + // Initial call — store original id, return InputRequiredResult with requestState + originalIds.set('echo_state', id); + const state = JSON.stringify({ + nonce: randomUUID(), + originalId: id + }); + sentStates.set('echo_state', state); + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm to continue', + requestedSchema: { + type: 'object', + properties: { + confirmed: { type: 'boolean', description: 'Confirm?' } + } + } + } + } + }, + requestState: state + } + }); + return; + } + + // Retry — verify requestState was echoed back correctly + const originalId = originalIds.get('echo_state'); + const sentState = sentStates.get('echo_state'); + + // Check 1: requestState must be present and byte-for-byte identical to + // what the server sent. The spec requires the client to echo back the + // *exact value* without inspecting, parsing, or modifying it — so a + // semantically-equal but re-serialized string is still a failure. + const stateErrors: string[] = []; + if (!requestState) { + stateErrors.push('Client did not include requestState in retry'); + } else if (requestState !== sentState) { + stateErrors.push( + 'requestState was not echoed back exactly — clients MUST NOT inspect, parse, or modify it' + ); + } + + checks.push({ + id: 'sep-2322-client-request-state-echoed', + name: 'MRTRClientRequestStateEchoed', + description: + 'Client MUST echo back the exact value of requestState when retrying', + status: stateErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: stateErrors.length > 0 ? stateErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + requestStateSent: sentState, + requestStateReceived: requestState, + originalId + } + }); + + // Check 2: JSON-RPC id must differ from original + const idErrors: string[] = []; + if (id === originalId) { + idErrors.push( + `JSON-RPC id is the same on retry (${id}) — MUST be different` + ); + } + + checks.push({ + id: 'sep-2322-client-jsonrpc-id-different', + name: 'MRTRClientJsonRpcIdDifferent', + description: + 'The JSON-RPC id MUST be different between the initial request and the retry', + status: idErrors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: idErrors.length > 0 ? idErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + originalId, + retryId: id + } + }); + + // Return complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: 'echo-state-ok' }] + } + }); + } + + function handleNoState( + id: string | number, + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response + ) { + if (!inputResponses) { + // Initial call — return InputRequiredResult WITHOUT requestState + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + inputRequests: { + confirm: { + method: 'elicitation/create', + params: { + message: 'Please confirm to continue (no state test)', + requestedSchema: { + type: 'object', + properties: { + confirmed: { type: 'boolean', description: 'Confirm?' } + } + } + } + } + } + // No requestState field! + } + }); + return; + } + + // Retry — verify client did NOT include requestState + const errors: string[] = []; + if (requestState !== undefined) { + errors.push( + `Client included requestState ("${requestState}") but server did not send one — MUST NOT include it` + ); + } + + checks.push({ + id: 'sep-2322-client-no-state-omitted', + name: 'MRTRClientNoStateOmitted', + description: + 'If InputRequiredResult does not contain requestState, client MUST NOT include one in the retry', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + requestStateReceived: requestState + } + }); + + // Return complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: 'no-state-ok' }] + } + }); + } + + function handleUnrelated( + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response, + id: string | number + ) { + // This tool should NEVER receive inputResponses or requestState — + // those belong to a different tool's MRTR flow + const errors: string[] = []; + if (inputResponses !== undefined) { + errors.push( + `Unrelated tool call included inputResponses from another tool's MRTR flow` + ); + } + if (requestState !== undefined) { + errors.push( + `Unrelated tool call included requestState from another tool's MRTR flow` + ); + } + + checks.push({ + id: 'sep-2322-client-parallel-isolation', + name: 'MRTRClientParallelIsolation', + description: + 'inputRequests and requestState MUST NOT be used for any other request the client may be sending', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + inputResponsesReceived: inputResponses, + requestStateReceived: requestState + } + }); + + // Return a normal complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + content: [{ type: 'text', text: 'unrelated-ok' }] + } + }); + } + + function handleNoResultType( + id: string | number, + inputResponses: Record | undefined, + checks: ConformanceCheck[], + res: Response + ) { + const checkId = 'sep-2322-default-result-type-complete'; + + // If the client retries this tool, it means it did NOT treat the result as complete + if (inputResponses) { + // Remove any prior SUCCESS for this check (emitted when we first sent the response) + const existingIdx = checks.findIndex((c) => c.id === checkId); + if (existingIdx !== -1) checks.splice(existingIdx, 1); + + checks.push({ + id: checkId, + name: 'DefaultResultTypeComplete', + description: + 'Client MUST assume resultType "complete" when not specified', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Client retried with inputResponses even though the result had no resultType (should default to complete)', + specReferences: MRTR_SPEC_REFERENCES + }); + res.json({ + jsonrpc: '2.0', + id, + result: { content: [{ type: 'text', text: 'unexpected-retry' }] } + }); + return; + } + + // Return a result WITHOUT resultType — client should treat as complete + checks.push({ + id: checkId, + name: 'DefaultResultTypeComplete', + description: + 'Client MUST assume resultType "complete" when not specified', + status: 'SUCCESS', + timestamp: new Date().toISOString(), + specReferences: MRTR_SPEC_REFERENCES + }); + + res.json({ + jsonrpc: '2.0', + id, + result: { + // Deliberately NO resultType field + content: [{ type: 'text', text: 'no-result-type-test-ok' }] + } + }); + } + + return app; +} + +export class MRTRClientScenario implements Scenario { + name = 'sep-2322-client-request-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + description = + 'Tests client MRTR behavior: requestState echo, no-state omission, and JSON-RPC id uniqueness (SEP-2322)'; + private app: express.Application | null = null; + private httpServer: ReturnType | null = null; + private checks: ConformanceCheck[] = []; + + async start(): Promise { + this.checks = []; + this.app = createMRTRServer(this.checks); + this.httpServer = this.app.listen(0); + const addr = this.httpServer.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { serverUrl: `http://localhost:${port}/mcp` }; + } + + async stop() { + if (this.httpServer) { + await new Promise((resolve) => this.httpServer!.close(resolve)); + this.httpServer = null; + } + this.app = null; + } + + getChecks(): ConformanceCheck[] { + const expectedSlugs = [ + 'sep-2322-client-request-state-echoed', + 'sep-2322-client-jsonrpc-id-different', + 'sep-2322-client-no-state-omitted', + 'sep-2322-client-parallel-isolation', + 'sep-2322-default-result-type-complete' + ]; + + for (const slug of expectedSlugs) { + if (!this.checks.find((c) => c.id === slug)) { + this.checks.push({ + id: slug, + name: slug, + description: `MRTR client check: ${slug}`, + status: 'FAILURE', + timestamp: new Date().toISOString(), + details: { + message: 'Tool was not called by client or MRTR flow not completed' + }, + specReferences: MRTR_SPEC_REFERENCES + }); + } + } + return this.checks; + } +} diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index e198311..3f31359 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -14,6 +14,7 @@ import { ToolsCallScenario } from './client/tools_call'; import { ElicitationClientDefaultsScenario } from './client/elicitation-defaults'; import { SSERetryScenario } from './client/sse-retry'; import { RequestMetadataScenario } from './client/request-metadata'; +import { MRTRClientScenario } from './client/mrtr-client'; // Import all new server test scenarios import { ServerInitializeScenario } from './server/lifecycle'; @@ -66,6 +67,24 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; +// InputRequiredResult scenarios from (SEP-2322) +import { + InputRequiredResultBasicElicitationScenario, + InputRequiredResultBasicSamplingScenario, + InputRequiredResultBasicListRootsScenario, + InputRequiredResultRequestStateScenario, + InputRequiredResultMultipleInputRequestsScenario, + InputRequiredResultMultiRoundScenario, + InputRequiredResultMissingInputResponseScenario, + InputRequiredResultNonToolRequestScenario, + InputRequiredResultResultTypeScenario, + InputRequiredResultUnsupportedMethodsScenario, + InputRequiredResultTamperedStateScenario, + InputRequiredResultCapabilityCheckScenario, + InputRequiredResultIgnoreExtraParamsScenario, + InputRequiredResultValidateInputScenario +} from './server/input-required-result'; + import { HttpHeaderValidationScenario, HttpCustomHeaderServerValidationScenario @@ -164,7 +183,23 @@ const allClientScenariosList: ClientScenario[] = [ // HTTP Standardization scenarios (SEP-2243) new HttpHeaderValidationScenario(), - new HttpCustomHeaderServerValidationScenario() + new HttpCustomHeaderServerValidationScenario(), + + // InputRequiredResult scenarios (SEP-2322) + new InputRequiredResultBasicElicitationScenario(), + new InputRequiredResultBasicSamplingScenario(), + new InputRequiredResultBasicListRootsScenario(), + new InputRequiredResultRequestStateScenario(), + new InputRequiredResultMultipleInputRequestsScenario(), + new InputRequiredResultMultiRoundScenario(), + new InputRequiredResultMissingInputResponseScenario(), + new InputRequiredResultNonToolRequestScenario(), + new InputRequiredResultResultTypeScenario(), + new InputRequiredResultUnsupportedMethodsScenario(), + new InputRequiredResultTamperedStateScenario(), + new InputRequiredResultCapabilityCheckScenario(), + new InputRequiredResultIgnoreExtraParamsScenario(), + new InputRequiredResultValidateInputScenario() ]; // Active client scenarios (excludes pending) @@ -210,6 +245,9 @@ const scenariosList: Scenario[] = [ ...draftScenariosList, ...extensionScenariosList, + // MRTR client conformance (SEP-2322) + new MRTRClientScenario(), + // HTTP Standardization scenarios (SEP-2243) new HttpStandardHeadersScenario(), new HttpCustomHeadersScenario(), diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts new file mode 100644 index 0000000..a5b73ab --- /dev/null +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -0,0 +1,166 @@ +/** + * Helpers for SEP-2322 conformance tests. + * + * Provides InputRequiredResult-specific type guards, mock response builders, + * and a stateless JSON-RPC transport helper. + */ + +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +// ─── JSON-RPC Types ────────────────────────────────────────────────────────── + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +// ─── Stateless RPC Helper ──────────────────────────────────────────────────── + +let nextId = 1; + +/** + * Send a stateless JSON-RPC request (SEP-2575 pattern). + * Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities. + */ +export async function sendRpc( + serverUrl: string, + method: string, + params?: Record +): Promise { + const id = nextId++; + + const enrichedParams = { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + sampling: {}, + elicitation: {}, + roots: { listChanged: true } + }, + ...(params?._meta as Record | undefined) + } + }; + + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params: enrichedParams }) + }); + + return (await response.json()) as JsonRpcResponse; +} + +// ─── InputRequiredResult Types ─────────────────────────────────────────────── + +export interface InputRequiredResultData { + resultType?: 'input_required'; + inputRequests?: Record; + requestState?: string; + _meta?: Record; + [key: string]: unknown; +} + +export interface InputRequestObject { + method: string; + params?: Record; +} + +// ─── Type Guards ───────────────────────────────────────────────────────────── + +/** + * Check if a JSON-RPC result is an InputRequiredResult. + */ +export function isInputRequiredResult( + result: Record | undefined +): result is InputRequiredResultData { + if (!result) return false; + if (result.resultType === 'input_required') return true; + return false; +} + +/** + * Check if a JSON-RPC result is a complete result (not input_required). + * complete is the default so if resultType is missing we assume it's complete. + */ +export function isCompleteResult( + result: Record | undefined +): boolean { + if (!result) return false; + if (result.resultType === 'input_required') return false; + return true; +} + +/** + * Extract inputRequests from an InputRequiredResult. + */ +export function getInputRequests( + result: InputRequiredResultData +): Record | undefined { + return result.inputRequests; +} + +// ─── Mock Response Builders ────────────────────────────────────────────────── + +/** + * Build a mock elicitation response (ElicitResult). + */ +export function mockElicitResponse( + content: Record +): Record { + return { + action: 'accept', + content + }; +} + +/** + * Build a mock sampling response (CreateMessageResult). + */ +export function mockSamplingResponse(text: string): Record { + return { + role: 'assistant', + content: { + type: 'text', + text + }, + model: 'test-model', + stopReason: 'endTurn' + }; +} + +/** + * Build a mock list roots response (ListRootsResult). + */ +export function mockListRootsResponse(): Record { + return { + roots: [ + { + uri: 'file:///test/root', + name: 'Test Root' + } + ] + }; +} + +// ─── Spec References ───────────────────────────────────────────────────────── + +/** + * SEP reference for InputRequiredResult / MRTR tests. + */ +export const MRTR_SPEC_REFERENCES = [ + { + id: 'SEP-2322', + url: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr' + } +]; diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts new file mode 100644 index 0000000..f471f58 --- /dev/null +++ b/src/scenarios/server/input-required-result.ts @@ -0,0 +1,1629 @@ +/** + * SEP-2322: InputRequiredResult - Ephemeral Workflow Tests + * + * Tests the ephemeral (stateless) workflow where servers respond with + * InputRequiredResult containing inputRequests and/or requestState, and + * clients retry with inputResponses and echoed requestState. + */ + +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + SpecVersion +} from '../../types'; +import { + sendRpc, + isInputRequiredResult, + isCompleteResult, + mockElicitResponse, + mockSamplingResponse, + mockListRootsResponse, + MRTR_SPEC_REFERENCES +} from './input-required-result-helpers'; + +// ─── A1: Basic Elicitation ──────────────────────────────────────────────────── + +export class InputRequiredResultBasicElicitationScenario implements ClientScenario { + name = 'input-required-result-basic-elicitation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_elicitation\` (no arguments required). + +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "user_name": { + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": { + "type": "object", + "properties": { + "name": { "type": "string" } + }, + "required": ["name"] + } + } + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"user_name"\`, return a complete result: + +\`\`\`json +{ + "content": [{ "type": "text", "text": "Hello, !" }] +} +\`\`\``; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Initial call — expect InputRequiredResult + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult but got a complete result. ' + + 'Server should return resultType: "input_required" with inputRequests.' + ); + } else { + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else if (!r1Result.inputRequests['user_name']) { + r1Errors.push('inputRequests missing expected key "user_name"'); + } else { + const req = r1Result.inputRequests['user_name']; + if (req.method !== 'elicitation/create') { + r1Errors.push( + `Expected method "elicitation/create", got "${req.method}"` + ); + } + } + } + + checks.push({ + id: 'sep-2322-elicitation-incomplete', + name: 'InputRequiredResultElicitationIncomplete', + description: + 'Server returns InputRequiredResult with elicitation inputRequest', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses — expect complete result + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: mockElicitResponse({ name: 'Alice' }) + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with inputResponses' + ); + } else { + const content = r2Result.content as + | Array<{ type: string; text?: string }> + | undefined; + if (!content || !Array.isArray(content) || content.length === 0) { + r2Errors.push('Complete result missing content array'); + } + } + + checks.push({ + id: 'sep-2322-elicitation-complete', + name: 'InputRequiredResultElicitationComplete', + description: + 'Server returns complete result after retry with inputResponses', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-elicitation-incomplete', + name: 'InputRequiredResultElicitationIncomplete', + description: + 'Server returns InputRequiredResult with elicitation inputRequest', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A2: Basic Sampling ────────────────────────────────────────────────────── + +export class InputRequiredResultBasicSamplingScenario implements ClientScenario { + name = 'input-required-result-basic-sampling'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_sampling\` (no arguments required). + +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "capital_question": { + "method": "sampling/createMessage", + "params": { + "messages": [{ + "role": "user", + "content": { "type": "text", "text": "What is the capital of France?" } + }], + "maxTokens": 100 + } + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"capital_question"\`, return a complete result with the sampling response text.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Initial call + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_sampling', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult with sampling inputRequest' + ); + } else { + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else { + const key = Object.keys(r1Result.inputRequests)[0]; + if (!key) { + r1Errors.push('inputRequests map is empty'); + } else { + const req = r1Result.inputRequests[key]; + if (req.method !== 'sampling/createMessage') { + r1Errors.push( + `Expected method "sampling/createMessage", got "${req.method}"` + ); + } + } + } + } + + checks.push({ + id: 'sep-2322-sampling-incomplete', + name: 'InputRequiredResultSamplingIncomplete', + description: + 'Server returns InputRequiredResult with sampling inputRequest', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_sampling', + arguments: {}, + inputResponses: { + [inputKey]: mockSamplingResponse('The capital of France is Paris.') + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with sampling response' + ); + } + + checks.push({ + id: 'sep-2322-sampling-complete', + name: 'InputRequiredResultSamplingComplete', + description: + 'Server returns complete result after retry with sampling response', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-sampling-incomplete', + name: 'InputRequiredResultSamplingIncomplete', + description: + 'Server returns InputRequiredResult with sampling inputRequest', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A3: Basic ListRoots ───────────────────────────────────────────────────── + +export class InputRequiredResultBasicListRootsScenario implements ClientScenario { + name = 'input-required-result-basic-list-roots'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_list_roots\` (no arguments required). + +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "client_roots": { + "method": "roots/list", + "params": {} + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing the key \`"client_roots"\` (a ListRootsResult with a \`roots\` array), return a complete result that references the provided roots.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Initial call + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_list_roots', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult with roots/list inputRequest' + ); + } else { + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else { + const key = Object.keys(r1Result.inputRequests)[0]; + if (!key) { + r1Errors.push('inputRequests map is empty'); + } else { + const req = r1Result.inputRequests[key]; + if (req.method !== 'roots/list') { + r1Errors.push( + `Expected method "roots/list", got "${req.method}"` + ); + } + } + } + } + + checks.push({ + id: 'sep-2322-list-roots-incomplete', + name: 'InputRequiredResultListRootsIncomplete', + description: + 'Server returns InputRequiredResult with roots/list inputRequest', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_list_roots', + arguments: {}, + inputResponses: { + [inputKey]: mockListRootsResponse() + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with roots response' + ); + } + + checks.push({ + id: 'sep-2322-list-roots-complete', + name: 'InputRequiredResultListRootsComplete', + description: + 'Server returns complete result after retry with roots response', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-list-roots-incomplete', + name: 'InputRequiredResultListRootsIncomplete', + description: + 'Server returns InputRequiredResult with roots/list inputRequest', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A4: Request State ────────────────────────────────────────────────────── + +export class InputRequiredResultRequestStateScenario implements ClientScenario { + name = 'input-required-result-request-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that requestState is correctly round-tripped in ephemeral InputRequiredResult flow (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_request_state\` (no arguments required). + +**Behavior (Round 1):** Return an \`InputRequiredResult\` with both \`inputRequests\` and \`requestState\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "confirm": { + "method": "elicitation/create", + "params": { + "message": "Please confirm", + "requestedSchema": { + "type": "object", + "properties": { "ok": { "type": "boolean" } }, + "required": ["ok"] + } + } + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` AND the echoed \`requestState\`, validate the state and return a complete result. The text content MUST include the word "state-ok" to confirm the server received and validated the requestState.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult'); + } else { + if (!r1Result.requestState) { + r1Errors.push('InputRequiredResult missing requestState'); + } + if (typeof r1Result.requestState !== 'string') { + r1Errors.push('requestState must be a string'); + } + if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } + } + + checks.push({ + id: 'sep-2322-request-state-incomplete', + name: 'InputRequiredResultRequestStateIncomplete', + description: + 'Server returns InputRequiredResult with both inputRequests and requestState', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + requestState + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', + arguments: {}, + inputResponses: { + [inputKey]: mockElicitResponse({ ok: true }) + }, + requestState: r1Result.requestState + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with requestState' + ); + } + + checks.push({ + id: 'sep-2322-request-state-complete', + name: 'InputRequiredResultRequestStateComplete', + description: + 'Server validates echoed requestState and returns complete result', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-request-state-incomplete', + name: 'InputRequiredResultRequestStateIncomplete', + description: + 'Server returns InputRequiredResult with both inputRequests and requestState', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A5: Multiple Input Requests ───────────────────────────────────────────── + +export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { + name = 'input-required-result-multiple-input-requests'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_multiple_inputs\` (no arguments required). + +**Behavior (Round 1):** Return an \`InputRequiredResult\` with multiple \`inputRequests\` — elicitation, sampling, and roots/list — plus \`requestState\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "user_name": { + "method": "elicitation/create", + "params": { + "message": "What is your name?", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + }, + "greeting": { + "method": "sampling/createMessage", + "params": { + "messages": [{ "role": "user", "content": { "type": "text", "text": "Generate a greeting" } }], + "maxTokens": 50 + } + }, + "client_roots": { + "method": "roots/list", + "params": {} + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` containing ALL keys and the echoed \`requestState\`, return a complete result.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multiple_inputs', + arguments: {} + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult'); + } else if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } else { + if (!r1Result.requestState) { + r1Errors.push('InputRequiredResult missing requestState'); + } + + const keys = Object.keys(r1Result.inputRequests); + if (keys.length < 3) { + r1Errors.push( + `Expected at least 3 inputRequests, got ${keys.length}` + ); + } + + // Check that required method types are present + const methods = new Set( + keys.map((k) => r1Result.inputRequests![k].method) + ); + if (!methods.has('elicitation/create')) { + r1Errors.push('Expected an elicitation/create inputRequest'); + } + if (!methods.has('sampling/createMessage')) { + r1Errors.push('Expected a sampling/createMessage inputRequest'); + } + if (!methods.has('roots/list')) { + r1Errors.push('Expected a roots/list inputRequest'); + } + if (methods.size < 3) { + r1Errors.push( + 'Expected inputRequests with different method types (elicitation + sampling + roots/list)' + ); + } + } + + checks.push({ + id: 'sep-2322-multiple-inputs-incomplete', + name: 'InputRequiredResultMultipleInputsIncomplete', + description: + 'Server returns InputRequiredResult with multiple inputRequests of different types', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Respond to all input requests + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputResponses: Record = {}; + for (const [key, req] of Object.entries(r1Result.inputRequests!)) { + if (req.method === 'elicitation/create') { + inputResponses[key] = mockElicitResponse({ name: 'Alice' }); + } else if (req.method === 'sampling/createMessage') { + inputResponses[key] = mockSamplingResponse('Hello there!'); + } else if (req.method === 'roots/list') { + inputResponses[key] = mockListRootsResponse(); + } + } + + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multiple_inputs', + arguments: {}, + inputResponses, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after providing all inputResponses' + ); + } + + checks.push({ + id: 'sep-2322-multiple-inputs-complete', + name: 'InputRequiredResultMultipleInputsComplete', + description: + 'Server returns complete result after all inputResponses are provided', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-multiple-inputs-incomplete', + name: 'InputRequiredResultMultipleInputsIncomplete', + description: + 'Server returns InputRequiredResult with multiple inputRequests of different types', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A6: Multi-Round ───────────────────────────────────────────────────────── + +export class InputRequiredResultMultiRoundScenario implements ClientScenario { + name = 'input-required-result-multi-round'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test multi-round ephemeral InputRequiredResult flow with evolving requestState (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_multi_round\` (no arguments required). + +**Behavior (Round 1):** Return an \`InputRequiredResult\` with an elicitation request and \`requestState\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "step1": { + "method": "elicitation/create", + "params": { + "message": "Step 1: What is your name?", + "requestedSchema": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\` for step1 + requestState, return ANOTHER \`InputRequiredResult\` with a new elicitation and updated requestState: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "step2": { + "method": "elicitation/create", + "params": { + "message": "Step 2: What is your favorite color?", + "requestedSchema": { + "type": "object", + "properties": { "color": { "type": "string" } }, + "required": ["color"] + } + } + } + }, + "requestState": "" +} +\`\`\` + +**Behavior (Round 3):** When called with \`inputResponses\` for step2 + updated requestState, return a complete result.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', + arguments: {} + }); + + const r1Result = r1.result; + let round1Complete = false; + + if ( + !r1.error && + r1Result && + isInputRequiredResult(r1Result) && + r1Result.inputRequests && + r1Result.requestState + ) { + round1Complete = true; + } + + checks.push({ + id: 'sep-2322-multi-round-r1', + name: 'InputRequiredResultMultiRoundR1', + description: + 'Round 1: Server returns InputRequiredResult with requestState', + status: round1Complete ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: round1Complete + ? undefined + : 'Expected InputRequiredResult with inputRequests and requestState', + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + if (!round1Complete || !isInputRequiredResult(r1Result)) return checks; + + // Round 2: Retry — expect another InputRequiredResult + const r1InputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', + arguments: {}, + inputResponses: { + [r1InputKey]: mockElicitResponse({ name: 'Alice' }) + }, + requestState: r1Result.requestState + }); + + const r2Result = r2.result; + let round2Complete = false; + + if ( + !r2.error && + r2Result && + isInputRequiredResult(r2Result) && + r2Result.inputRequests && + r2Result.requestState + ) { + // requestState should have changed + if (r2Result.requestState !== r1Result.requestState) { + round2Complete = true; + } + } + + checks.push({ + id: 'sep-2322-multi-round-r2', + name: 'InputRequiredResultMultiRoundR2', + description: + 'Round 2: Server returns another InputRequiredResult with updated requestState', + status: round2Complete ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: round2Complete + ? undefined + : 'Expected new InputRequiredResult with different requestState', + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + + if (!round2Complete || !isInputRequiredResult(r2Result)) return checks; + + // Round 3: Final retry — expect complete result + const r2InputKey = Object.keys(r2Result.inputRequests!)[0]; + const r3 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', + arguments: {}, + inputResponses: { + [r2InputKey]: mockElicitResponse({ color: 'blue' }) + }, + requestState: r2Result.requestState + }); + + const r3Result = r3.result; + const round3Complete = + !r3.error && r3Result != null && isCompleteResult(r3Result); + + checks.push({ + id: 'sep-2322-multi-round-r3', + name: 'InputRequiredResultMultiRoundR3', + description: 'Round 3: Server returns complete result', + status: round3Complete ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: round3Complete + ? undefined + : 'Expected complete result after final retry', + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r3Result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-multi-round-r1', + name: 'InputRequiredResultMultiRoundR1', + description: + 'Round 1: Server returns InputRequiredResult with requestState', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A7: Missing Input Response ────────────────────────────────────────────── + +export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { + name = 'input-required-result-missing-input-response'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). + +**Server Implementation Requirements:** + +Use the same tool as A1: \`test_input_required_result_elicitation\`. + +**Behavior:** When the client retries with \`inputResponses\` that are missing required keys or contain wrong keys, the server SHOULD respond with a new \`InputRequiredResult\` re-requesting the missing information (NOT a JSON-RPC error).`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Send wrong inputResponses (wrong key) + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + wrong_key: mockElicitResponse({ data: 'wrong' }) + } + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + // A JSON-RPC error is acceptable but the SEP prefers re-requesting + r1Errors.push( + 'Server returned JSON-RPC error instead of re-requesting via InputRequiredResult. ' + + 'SEP-2322 recommends servers re-request missing information.' + ); + } else if (!r1Result) { + r1Errors.push('No result in response'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push( + 'Expected InputRequiredResult re-requesting missing information, ' + + 'but got a complete result' + ); + } + + checks.push({ + id: 'sep-2322-missing-response-rerequests', + name: 'InputRequiredResultMissingResponseRerequests', + description: + 'Server re-requests missing inputResponses via new InputRequiredResult', + status: r1Errors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-missing-response-rerequests', + name: 'InputRequiredResultMissingResponseRerequests', + description: + 'Server re-requests missing inputResponses via new InputRequiredResult', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A9: Non-Tool Request (prompts/get) ────────────────────────────────────── + +export class InputRequiredResultNonToolRequestScenario implements ClientScenario { + name = 'input-required-result-non-tool-request'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test InputRequiredResult on a non-tool request (prompts/get) to verify InputRequiredResult is universal (SEP-2322). + +**Server Implementation Requirements:** + +Implement a prompt named \`test_input_required_result_prompt\` that requires elicitation input. + +**Behavior (Round 1):** When \`prompts/get\` is called for \`test_input_required_result_prompt\` without \`inputResponses\`, return an \`InputRequiredResult\`: + +\`\`\`json +{ + "resultType": "input_required", + "inputRequests": { + "user_context": { + "method": "elicitation/create", + "params": { + "message": "What context should the prompt use?", + "requestedSchema": { + "type": "object", + "properties": { "context": { "type": "string" } }, + "required": ["context"] + } + } + } + } +} +\`\`\` + +**Behavior (Round 2):** When called with \`inputResponses\`, return a complete \`GetPromptResult\`.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1 + const r1 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt' + }); + + const r1Result = r1.result; + const r1Errors: string[] = []; + + if (r1.error) { + r1Errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult from prompts/get'); + } else if (!r1Result.inputRequests) { + r1Errors.push('InputRequiredResult missing inputRequests'); + } + + checks.push({ + id: 'sep-2322-non-tool-incomplete', + name: 'InputRequiredResultNonToolIncomplete', + description: + 'prompts/get returns InputRequiredResult with inputRequests', + status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + + // Round 2: Retry with inputResponses + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt', + inputResponses: { + [inputKey]: mockElicitResponse({ context: 'test context' }) + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push('Expected complete GetPromptResult after retry'); + } else if (!r2Result.messages) { + r2Errors.push( + 'Complete result missing messages (expected GetPromptResult)' + ); + } + + checks.push({ + id: 'sep-2322-non-tool-complete', + name: 'InputRequiredResultNonToolComplete', + description: + 'prompts/get returns complete GetPromptResult after retry with inputResponses', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } + } catch (error) { + checks.push({ + id: 'sep-2322-non-tool-incomplete', + name: 'InputRequiredResultNonToolIncomplete', + description: + 'prompts/get returns InputRequiredResult with inputRequests', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A10: ResultType Included ──────────────────────────────────────────────── + +export class InputRequiredResultResultTypeScenario implements ClientScenario { + name = 'input-required-result-result-type'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server explicitly includes resultType field in InputRequiredResult responses (SEP-2322). + +**Server Implementation Requirements:** + +Uses the same tool as A1: \`test_input_required_result_elicitation\`. + +This scenario verifies that the resultType field is explicitly present in the response (not just inferred).`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {} + }); + + const r1Result = r1.result; + const errors: string[] = []; + + if (r1.error) { + errors.push(`JSON-RPC error: ${r1.error.message}`); + } else if (!r1Result) { + errors.push('No result in response'); + } else if (!('resultType' in r1Result)) { + errors.push( + 'resultType field is missing from response. Servers MUST include resultType to indicate the type of the result.' + ); + } else if (r1Result.resultType !== 'input_required') { + errors.push( + `Expected resultType "input_required", got "${r1Result.resultType}"` + ); + } + + checks.push({ + id: 'sep-2322-result-type-included', + name: 'ResultTypeIncluded', + description: + 'Server includes resultType field in InputRequiredResult response', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r1Result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-result-type-included', + name: 'ResultTypeIncluded', + description: + 'Server includes resultType field in InputRequiredResult response', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A11: Unsupported Methods ──────────────────────────────────────────────── + +export class InputRequiredResultUnsupportedMethodsScenario implements ClientScenario { + name = 'input-required-result-unsupported-methods'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server does NOT return InputRequiredResult on unsupported methods (SEP-2322). + +Servers MUST NOT send InputRequiredResult responses on any client requests other than the supported ones (prompts/get, resources/read, tools/call, tasks/result).`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + const errors: string[] = []; + + const unsupportedMethods = ['tools/list', 'prompts/list']; + + try { + for (const method of unsupportedMethods) { + const resp = await sendRpc(serverUrl, method, {}); + if ( + resp.result && + (resp.result as Record).resultType === + 'input_required' + ) { + errors.push( + `${method} returned InputRequiredResult, but it is not a supported method for MRTR` + ); + } + } + + checks.push({ + id: 'sep-2322-not-on-unsupported-requests', + name: 'NotOnUnsupportedRequests', + description: + 'Server does not return InputRequiredResult on unsupported methods', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES + }); + } catch (error) { + checks.push({ + id: 'sep-2322-not-on-unsupported-requests', + name: 'NotOnUnsupportedRequests', + description: + 'Server does not return InputRequiredResult on unsupported methods', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A12: Tampered State Rejection ─────────────────────────────────────────── + +export class InputRequiredResultTamperedStateScenario implements ClientScenario { + name = 'input-required-result-tampered-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server rejects tampered requestState (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_tampered_state\` (no arguments required). + +**Behavior (Round 1):** When called without inputResponses, return an InputRequiredResult with +integrity-protected requestState (e.g. HMAC-signed). + +**Behavior (Round 2 - tampered):** When called with a modified/tampered requestState, return a +JSON-RPC error (code -32602 or similar) indicating integrity check failure.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Round 1: Get valid InputRequiredResult with signed requestState + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_tampered_state', + arguments: {} + }); + + const r1Result = r1.result; + if (r1.error || !r1Result || !isInputRequiredResult(r1Result)) { + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Prerequisite failed: could not get initial InputRequiredResult with requestState', + specReferences: MRTR_SPEC_REFERENCES + }); + return checks; + } + + if (!r1Result.requestState) { + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: + 'Server did not include requestState in InputRequiredResult', + specReferences: MRTR_SPEC_REFERENCES + }); + return checks; + } + + // Round 2: Tamper with the requestState and retry + const tamperedState = r1Result.requestState + '-TAMPERED'; + const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_tampered_state', + arguments: {}, + inputResponses: { + [inputKey]: mockElicitResponse({ ok: true }) + }, + requestState: tamperedState + }); + + const errors: string[] = []; + + if (!r2.error) { + // The only acceptable response to tampered state is a JSON-RPC error. + // Returning a complete result OR re-prompting (InputRequiredResult) both + // indicate the server did not reject the tampered state. + if (r2.result && isCompleteResult(r2.result)) { + errors.push( + 'Server accepted tampered requestState and returned complete result. ' + + 'Servers MUST reject state that fails integrity verification.' + ); + } else { + errors.push( + 'Server did not return a JSON-RPC error for tampered requestState. ' + + 'Servers MUST reject state that fails integrity verification.' + ); + } + } + + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { tamperedResponse: r2.result ?? r2.error } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-reject-tampered-state', + name: 'RejectTamperedState', + description: 'Server rejects tampered requestState with error', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A13: Respect Client Capabilities ──────────────────────────────────────── + +export class InputRequiredResultCapabilityCheckScenario implements ClientScenario { + name = 'input-required-result-capability-check'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server only sends inputRequests for capabilities the client declared (SEP-2322). + +**Server Implementation Requirements:** + +Implement a tool named \`test_input_required_result_capabilities\` (no arguments required). + +**Behavior:** Read client capabilities from \`_meta['io.modelcontextprotocol/clientCapabilities']\`. +Only include inputRequests for methods the client supports. For example, if the client declares +\`sampling: {}\` but NOT \`elicitation\`, only include \`sampling/createMessage\` inputRequests.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Send request with only sampling capability (no elicitation) + const resp = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_capabilities', + arguments: {}, + _meta: { + 'io.modelcontextprotocol/clientCapabilities': { + sampling: {} + // deliberately omitting elicitation + } + } + }); + + const result = resp.result; + const errors: string[] = []; + + if (resp.error) { + errors.push(`JSON-RPC error: ${resp.error.message}`); + } else if (!result) { + errors.push('No result in response'); + } else if (isInputRequiredResult(result) && result.inputRequests) { + // Check that no elicitation requests are included (client didn't declare it) + for (const [key, req] of Object.entries(result.inputRequests)) { + if (req.method === 'elicitation/create') { + errors.push( + `Server included elicitation/create inputRequest (key: "${key}") ` + + 'but client did not declare elicitation capability' + ); + } + } + } else if (isCompleteResult(result)) { + errors.push( + 'Server returned complete result; expected InputRequiredResult with sampling-only inputRequests' + ); + } + + checks.push({ + id: 'sep-2322-respect-client-capabilities', + name: 'RespectClientCapabilities', + description: + 'Server only includes inputRequests for declared client capabilities', + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-respect-client-capabilities', + name: 'RespectClientCapabilities', + description: + 'Server only includes inputRequests for declared client capabilities', + status: 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A14: Ignore Unexpected Params ─────────────────────────────────────────── + +export class InputRequiredResultIgnoreExtraParamsScenario implements ClientScenario { + name = 'input-required-result-ignore-extra-params'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server ignores unexpected extra parameters in InputResponses (SEP-2322). + +**Server Implementation Requirements:** + +Uses the same tool as A1: \`test_input_required_result_elicitation\`. + +This scenario sends correct inputResponses PLUS extra unrecognized keys. The server SHOULD ignore +the extra keys and complete normally.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Send retry with correct inputResponses + extra unknown keys + const resp = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: mockElicitResponse({ name: 'Alice' }), + unknown_extra_key: { action: 'accept', content: { foo: 'bar' } }, + another_unexpected: { action: 'accept', content: { baz: 123 } } + } + }); + + const result = resp.result; + const errors: string[] = []; + + if (resp.error) { + errors.push( + `Server returned JSON-RPC error when extra params were included: ${resp.error.message}. ` + + 'Servers SHOULD ignore unrecognized information.' + ); + } else if (!result) { + errors.push('No result in response'); + } else if (!isCompleteResult(result)) { + errors.push( + 'Server did not return complete result when valid inputResponses were provided alongside extra keys. ' + + 'Servers SHOULD ignore information they do not recognize.' + ); + } + + checks.push({ + id: 'sep-2322-ignore-unexpected-params', + name: 'IgnoreUnexpectedParams', + description: 'Server ignores extra unrecognized keys in inputResponses', + status: errors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-ignore-unexpected-params', + name: 'IgnoreUnexpectedParams', + description: 'Server ignores extra unrecognized keys in inputResponses', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} + +// ─── A15: Validate InputResponses ──────────────────────────────────────────── + +export class InputRequiredResultValidateInputScenario implements ClientScenario { + name = 'input-required-result-validate-input'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that server validates InputResponses and returns appropriate errors (SEP-2322). + +**Server Implementation Requirements:** + +Uses the same tool as A1: \`test_input_required_result_elicitation\`. + +This scenario sends completely invalid inputResponses structures. The server SHOULD validate them +and return a JSON-RPC error or a new InputRequiredResult.`; + + async run(serverUrl: string): Promise { + const checks: ConformanceCheck[] = []; + + try { + // Send inputResponses with invalid structure (number instead of object for the response) + const resp = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: { + user_name: 12345 as unknown as Record + } + }); + + const validateErrors: string[] = []; + + // Server should either error or re-request — NOT return a complete result + // with the invalid data silently accepted + if (!resp.error && resp.result && isCompleteResult(resp.result)) { + validateErrors.push( + 'Server accepted invalid inputResponses (number instead of object) and returned complete result. ' + + 'Servers SHOULD validate InputResponses data.' + ); + } + + checks.push({ + id: 'sep-2322-validate-input-responses', + name: 'ValidateInputResponses', + description: 'Server validates InputResponses structure', + status: validateErrors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: + validateErrors.length > 0 ? validateErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: resp.result, error: resp.error } + }); + + // Also test: send completely malformed inputResponses (null) + const resp2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', + arguments: {}, + inputResponses: null as unknown as Record + }); + + const protocolErrors: string[] = []; + + if (!resp2.error && resp2.result && isCompleteResult(resp2.result)) { + protocolErrors.push( + 'Server accepted null inputResponses and returned complete result. ' + + 'Protocol errors SHOULD return a JSON-RPC error response.' + ); + } + + checks.push({ + id: 'sep-2322-error-on-protocol-error', + name: 'ErrorOnProtocolError', + description: + 'Server returns JSON-RPC error for protocol-level input errors', + status: protocolErrors.length === 0 ? 'SUCCESS' : 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: + protocolErrors.length > 0 ? protocolErrors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: resp2.result, error: resp2.error } + }); + } catch (error) { + checks.push({ + id: 'sep-2322-validate-input-responses', + name: 'ValidateInputResponses', + description: 'Server validates InputResponses structure', + status: 'WARNING', + timestamp: new Date().toISOString(), + errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, + specReferences: MRTR_SPEC_REFERENCES + }); + } + + return checks; + } +} diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts new file mode 100644 index 0000000..e12dbe1 --- /dev/null +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -0,0 +1,125 @@ +/** + * SEP-2322 MRTR negative tests. + * + * Positive tests run via all-scenarios.test.ts against the everything-server + * (which implements MRTR in its stateless path). These negative tests run + * against a deliberately broken server to verify checks emit FAILURE. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { createServer } from 'net'; +import path from 'path'; +import { + InputRequiredResultResultTypeScenario, + InputRequiredResultUnsupportedMethodsScenario, + InputRequiredResultTamperedStateScenario +} from './input-required-result'; + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const port = (server.address() as { port: number }).port; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); +} + +function startServer(scriptPath: string, port: number): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === 'win32'; + const proc = spawn('npx', ['tsx', scriptPath], { + env: { ...process.env, PORT: port.toString() }, + stdio: ['ignore', 'pipe', 'pipe'], + shell: isWindows + }); + let stderr = ''; + proc.stderr?.on('data', (d) => (stderr += d.toString())); + const timeout = setTimeout(() => { + proc.kill('SIGKILL'); + reject( + new Error(`Server ${scriptPath} failed to start within 30s: ${stderr}`) + ); + }, 30000); + proc.stdout?.on('data', (data) => { + if (data.toString().includes('running on')) { + clearTimeout(timeout); + resolve(proc); + } + }); + proc.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +function stopServer(proc: ChildProcess | null): Promise { + return new Promise((resolve) => { + if (!proc || proc.killed) return resolve(); + const t = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 5000); + proc.once('exit', () => { + clearTimeout(t); + resolve(); + }); + proc.kill('SIGTERM'); + }); +} + +describe('SEP-2322 MRTR negative tests', () => { + let serverProcess: ChildProcess | null = null; + let SERVER_URL: string; + + beforeAll(async () => { + const port = await getFreePort(); + SERVER_URL = `http://localhost:${port}/mcp`; + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2322-mrtr-broken-server.ts' + ), + port + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE for sep-2322-result-type-included against server that omits resultType', async () => { + const scenario = new InputRequiredResultResultTypeScenario(); + const checks = await scenario.run(SERVER_URL); + + const resultTypeCheck = checks.find( + (c) => c.id === 'sep-2322-result-type-included' + ); + expect(resultTypeCheck).toBeDefined(); + expect(resultTypeCheck?.status).toBe('FAILURE'); + }, 10000); + + it('emits FAILURE for sep-2322-not-on-unsupported-requests against server returning InputRequiredResult on tools/list', async () => { + const scenario = new InputRequiredResultUnsupportedMethodsScenario(); + const checks = await scenario.run(SERVER_URL); + + const unsupportedCheck = checks.find( + (c) => c.id === 'sep-2322-not-on-unsupported-requests' + ); + expect(unsupportedCheck).toBeDefined(); + expect(unsupportedCheck?.status).toBe('FAILURE'); + }, 10000); + + it('emits FAILURE for sep-2322-reject-tampered-state against server that accepts tampered state', async () => { + const scenario = new InputRequiredResultTamperedStateScenario(); + const checks = await scenario.run(SERVER_URL); + + const tamperedCheck = checks.find( + (c) => c.id === 'sep-2322-reject-tampered-state' + ); + expect(tamperedCheck).toBeDefined(); + expect(tamperedCheck?.status).toBe('FAILURE'); + }, 10000); +}); diff --git a/src/seps/sep-2322.yaml b/src/seps/sep-2322.yaml new file mode 100644 index 0000000..143a7ed --- /dev/null +++ b/src/seps/sep-2322.yaml @@ -0,0 +1,116 @@ +sep: 2322 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr +requirements: + # ── ResultType (basic/index.mdx) ─────────────────────────────────────────── + - check: sep-2322-result-type-included + text: 'The resultType field MUST be included to indicate the type of the result.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#resulttype + + - check: sep-2322-default-result-type-complete + text: 'If resultType is not specified, clients MUST assume a default value of "complete" for backwards compatibility.' + url: https://modelcontextprotocol.io/specification/draft/basic/index#resulttype + + # ── Supported Requests ───────────────────────────────────────────────────── + - check: sep-2322-not-on-unsupported-requests + text: 'Servers MUST NOT send InputRequiredResult responses on any other client requests.' + + # ── Server Requirements (Basic Workflow) ─────────────────────────────────── + - check: sep-2322-elicitation-incomplete + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (elicitation variant)' + + - check: sep-2322-sampling-incomplete + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling variant)' + + - check: sep-2322-list-roots-incomplete + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots variant)' + + - check: sep-2322-reject-tampered-state + text: 'If requestState influences authorization, resource access, or business logic, servers MUST protect its integrity and MUST reject state that fails verification.' + + - check: sep-2322-request-state-incomplete + text: 'Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response.' + + - check: sep-2322-respect-client-capabilities + text: 'Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities.' + + # ── Client Requirements (Basic Workflow) ─────────────────────────────────── + - check: sep-2322-client-request-state-echoed + text: 'If an InputRequiredResult contains the requestState field, the client MUST echo back the exact value of that field when retrying the original request. Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents.' + + - check: sep-2322-client-no-state-omitted + text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry.' + + - check: sep-2322-client-jsonrpc-id-different + text: 'The JSON-RPC id MUST be different between the initial request and the retry, as they are independent requests.' + + - check: sep-2322-client-parallel-isolation + text: "Both the inputRequests and requestState fields affect only the client's retry of the original request. They MUST NOT be used for any other request that the client may be sending in parallel." + + # Scenario flow gates (sep-2322-*-complete, sep-2322-multi-round-r*, + # sep-2322-non-tool-*, sep-2322-multiple-inputs-incomplete) intentionally + # have no rows here: they verify the end-to-end MRTR flow rather than a + # specific RFC-2119 sentence, so they surface in the traceability manifest's + # `untracked` list instead of claiming requirement coverage. + + # ── Error Handling ───────────────────────────────────────────────────────── + - check: sep-2322-validate-input-responses + text: 'Servers SHOULD validate that the data provided by the client is a valid InputResponses object and that the information inside can be correctly parsed.' + + - check: sep-2322-error-on-protocol-error + text: 'Protocol errors (malformed JSON, invalid schema, internal server errors) SHOULD return a JSON-RPC error response with an appropriate error code and message.' + + - check: sep-2322-ignore-unexpected-params + text: 'If additional, unexpected parameters are provided in the InputResponses object, the server SHOULD ignore any information it does not recognize or need.' + + - check: sep-2322-missing-response-rerequests + text: 'If the client fails to send all the information requested in a previous InputRequests, and the missing information is necessary for the server to process the request, the server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error.' + + # ── Excluded requirements ────────────────────────────────────────────────── + + - text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request.' + excluded: 'inputRequests is a JSON object; duplicate keys are collapsed by JSON parsing before the harness can observe them, so key uniqueness is not testable at the protocol level' + + - text: 'Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern.' + excluded: 'Architectural migration statement; tested indirectly through all MRTR scenarios' + + - text: 'servers MUST treat requestState as an attacker-controlled input' + excluded: 'Internal security posture; not observable at protocol level' + + - text: 'servers MUST protect its integrity (e.g. HMAC or AEAD)' + excluded: 'Internal implementation choice about encryption/signing; not observable at protocol level' + + - text: 'servers SHOULD include the authenticated principal, a short expiry (TTL), and an identifier for the originating request inside the integrity-protected requestState payload and verify each on receipt' + excluded: 'Internal requestState format; not observable at protocol level' + + - text: 'Servers for which a given requestState must be consumed at most once MUST enforce that invariant server-side' + excluded: 'Internal enforcement policy; conformance harness cannot determine which servers require single-use semantics' + + - text: 'Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request' + excluded: 'Server-internal robustness assumption; not observable at protocol level' + + - text: 'Servers MUST validate request state as described in the server requirements above.' + excluded: 'Duplicates integrity-protection requirements above; internal security detail' + + - text: 'Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'When tasks/get shows status input_required, clients MUST call tasks/result to get the inputRequests and optional requestState.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'Clients SHOULD construct the results of those requests and call tasks/input_response with the inputResponses & requestState (if present).' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'Receivers MUST reject tasks/input_response requests for tasks that are not in input_required status with error code -32602 (Invalid params).' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'When a receiver receives a tasks/result request for a task in working status, it MUST block the response until the task reaches a terminal status or input_required status.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'When a receiver receives a tasks/result request for a task in input_required status, it MUST return an InputRequiredResult containing the inputRequests that the requestor must fulfill.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance' + + - text: 'After sending tasks/input_response, the requestor SHOULD resume polling via tasks/get.' + excluded: 'Tasks moved to an extension as of SEP-2663; no longer part of core conformance'