From 23226ed92948e8e5adf215a9fe185b757b64b53c Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Thu, 14 May 2026 17:59:46 -0700 Subject: [PATCH 01/13] Agent WIP --- .../typescript/sep-2322-mrtr-server.ts | 872 ++++++++++++++++++ .../servers/typescript/sep-2322-no-mrtr.ts | 155 ++++ src/scenarios/index.ts | 85 +- src/scenarios/server/client-helper.ts | 6 + ...rs.ts => input-required-result-helpers.ts} | 34 +- ...asks.ts => input-required-result-tasks.ts} | 142 +-- ...ete-result.ts => input-required-result.ts} | 342 +++---- src/scenarios/server/negative.test.ts | 31 + src/scenarios/server/sep-2322-mrtr.test.ts | 122 +++ src/seps/sep-2322.yaml | 184 ++++ 10 files changed, 1672 insertions(+), 301 deletions(-) create mode 100644 examples/servers/typescript/sep-2322-mrtr-server.ts create mode 100644 examples/servers/typescript/sep-2322-no-mrtr.ts rename src/scenarios/server/{incomplete-result-helpers.ts => input-required-result-helpers.ts} (72%) rename src/scenarios/server/{incomplete-result-tasks.ts => input-required-result-tasks.ts} (80%) rename src/scenarios/server/{incomplete-result.ts => input-required-result.ts} (72%) create mode 100644 src/scenarios/server/sep-2322-mrtr.test.ts create mode 100644 src/seps/sep-2322.yaml diff --git a/examples/servers/typescript/sep-2322-mrtr-server.ts b/examples/servers/typescript/sep-2322-mrtr-server.ts new file mode 100644 index 00000000..ea416b2e --- /dev/null +++ b/examples/servers/typescript/sep-2322-mrtr-server.ts @@ -0,0 +1,872 @@ +#!/usr/bin/env node + +/** + * SEP-2322 MRTR Reference Server + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + StreamableHTTPServerTransport, + EventStore, + EventId, + StreamId +} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + ListToolsRequestSchema, + ListPromptsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + CancelTaskRequestSchema +} from '@modelcontextprotocol/sdk/types.js'; +import express from 'express'; +import { randomUUID } from 'crypto'; +import { z } from 'zod'; + +interface InputRequest { + method: string; + params?: Record; +} + +const ExtendedCallToolRequestSchema = z.object({ + method: z.literal('tools/call'), + params: z + .object({ + name: z.string(), + arguments: z.record(z.string(), z.unknown()).optional(), + task: z.object({ ttl: z.number().optional() }).passthrough().optional(), + _meta: z.record(z.string(), z.unknown()).optional() + }) + .passthrough() +}); + +const ExtendedGetPromptRequestSchema = z.object({ + method: z.literal('prompts/get'), + params: z + .object({ + name: z.string(), + arguments: z.record(z.string(), z.unknown()).optional(), + _meta: z.record(z.string(), z.unknown()).optional() + }) + .passthrough() +}); + +const TasksInputResponseRequestSchema = z.object({ + method: z.literal('tasks/input_response'), + params: z.object({}).passthrough() +}); + +type TaskKind = 'basic' | 'multi'; + +type TaskStatus = + | 'working' + | 'input_required' + | 'completed' + | 'failed' + | 'cancelled'; + +interface TaskState { + taskId: string; + kind: TaskKind; + status: TaskStatus; + ttl: number | null; + createdAt: string; + lastUpdatedAt: string; + pollInterval: number; + inputRound: number; + inputRequests?: Record; + finalContent?: string; +} + +const tasks = new Map(); + +function nowIso(): string { + return new Date().toISOString(); +} + +function createTask(kind: TaskKind, ttl?: number): TaskState { + const now = nowIso(); + return { + taskId: randomUUID(), + kind, + status: 'working', + ttl: ttl ?? null, + createdAt: now, + lastUpdatedAt: now, + pollInterval: 250, + inputRound: 0 + }; +} + +function updateTask(task: TaskState, patch: Partial): TaskState { + Object.assign(task, patch, { lastUpdatedAt: nowIso() }); + return task; +} + +function taskView(task: TaskState) { + return { + taskId: task.taskId, + status: task.status, + ttl: task.ttl, + createdAt: task.createdAt, + lastUpdatedAt: task.lastUpdatedAt, + pollInterval: task.pollInterval + }; +} + +function ackResult(taskId: string) { + return { + acknowledged: true, + _meta: { + 'io.modelcontextprotocol/related-task': { taskId } + } + }; +} + +function getInputText(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'; +} + +class InMemoryEventStore implements EventStore { + private events: Map = + new Map(); + private counter = 0; + + async storeEvent(streamId: StreamId, message: string): Promise { + const id = String(++this.counter); + this.events.set(id, { streamId, message }); + return id; + } + + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: string) => Promise } + ): Promise { + const startId = parseInt(lastEventId, 10); + for (const [id, event] of this.events) { + if (parseInt(id, 10) > startId) { + await send(id, event.message); + } + } + return ''; + } +} + +function createServer(): Server { + const server = new Server( + { name: 'sep-2322-mrtr-server', version: '1.0.0' }, + { + capabilities: { + tools: {}, + prompts: {}, + elicitation: {}, + tasks: { + list: {}, + cancel: {}, + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test_input_required_result_elicitation', + description: + 'Test tool: returns InputRequiredResult with elicitation request', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_sampling', + description: + 'Test tool: returns InputRequiredResult with sampling request', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_list_roots', + description: + 'Test tool: returns InputRequiredResult with list roots request', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_request_state', + description: 'Test tool: returns InputRequiredResult with requestState', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_multiple_inputs', + description: + 'Test tool: returns InputRequiredResult with multiple input requests', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_multi_round', + description: + 'Test tool: returns InputRequiredResult across multiple rounds', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_task', + description: 'Test tool: task-based InputRequiredResult workflow', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_task_multi_input', + description: 'Test tool: task-based multi-round InputRequiredResult', + inputSchema: { type: 'object' as const, properties: {} } + } + ] + })); + + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [ + { + name: 'test_input_required_result_prompt', + description: + 'Test prompt: returns InputRequiredResult with elicitation request' + } + ] + })); + + server.setRequestHandler(ExtendedGetPromptRequestSchema, async (request) => { + const params = request.params as Record; + if (params.name !== 'test_input_required_result_prompt') { + throw new Error(`Unknown prompt: ${params.name}`); + } + + const inputResponses = params.inputResponses as + | Record + | undefined; + + if (inputResponses?.['user_context']) { + const context = getInputText(inputResponses['user_context'], 'context'); + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with context: ${context}` + } + } + ] + }; + } + + return { + 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'] + } + } + } + } + }; + }); + + server.setRequestHandler(ExtendedCallToolRequestSchema, async (request) => { + const params = request.params as Record; + const toolName = params.name as string; + const inputResponses = params.inputResponses as + | Record + | undefined; + const requestState = params.requestState as string | undefined; + + switch (toolName) { + case 'test_input_required_result_elicitation': { + if (inputResponses?.['user_name']) { + const name = getInputText(inputResponses['user_name'], 'name'); + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } + + return { + resultType: 'input_required', + 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_sampling': { + if (inputResponses?.['sample_request']) { + const sample = inputResponses['sample_request'] as Record< + string, + unknown + >; + const content = sample.content as Record | undefined; + return { + content: [ + { + type: 'text', + text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}` + } + ] + }; + } + + return { + 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 + } + } + } + }; + } + + case '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 { + content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] + }; + } + + return { + resultType: 'input_required', + inputRequests: { + roots_request: { + method: 'roots/list', + params: {} + } + } + }; + } + + case '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 { + content: [ + { type: 'text', text: 'state-ok: requestState validated' } + ] + }; + } + } + + return { + 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() + }) + }; + } + + case '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 name = getInputText(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 { + content: [ + { + type: 'text', + text: `Name: ${name}; Greeting: ${greeting}; Roots: ${roots.length}` + } + ] + }; + } + } + + return { + 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() + }) + }; + } + + case 'test_input_required_result_multi_round': { + if (!requestState) { + return { + 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 name = getInputText(inputResponses['step1'], 'name'); + return { + 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, + nonce: randomUUID() + }) + }; + } + + if (state.round === 2 && inputResponses?.['step2']) { + const name = typeof state.name === 'string' ? state.name : 'friend'; + const color = getInputText(inputResponses['step2'], 'color'); + return { + content: [ + { + type: 'text', + text: `Multi-round complete for ${name} who likes ${color}` + } + ] + }; + } + + return { + 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() }) + }; + } + + case 'test_input_required_result_task': { + const taskMeta = params.task as Record | undefined; + if (!taskMeta) { + return { + content: [ + { + type: 'text', + text: 'Call with task metadata for task workflow' + } + ] + }; + } + + const task = createTask( + 'basic', + typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined + ); + task.inputRequests = { + user_input: { + method: 'elicitation/create', + params: { + message: 'What input should the task use?', + requestedSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] + } + } + } + }; + tasks.set(task.taskId, task); + + setTimeout(() => { + const current = tasks.get(task.taskId); + if (current?.status === 'working') { + updateTask(current, { status: 'input_required' }); + } + }, 100); + + return { task: taskView(task) }; + } + + case 'test_input_required_result_task_multi_input': { + const taskMeta = params.task as Record | undefined; + if (!taskMeta) { + return { + content: [{ type: 'text', text: 'Call with task metadata' }] + }; + } + + const task = createTask( + 'multi', + typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined + ); + task.inputRequests = { + first_input: { + method: 'elicitation/create', + params: { + message: 'First input needed', + requestedSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] + } + } + } + }; + tasks.set(task.taskId, task); + + setTimeout(() => { + const current = tasks.get(task.taskId); + if (current?.status === 'working') { + updateTask(current, { status: 'input_required' }); + } + }, 100); + + return { task: taskView(task) }; + } + + default: + throw new Error(`Unknown tool: ${toolName}`); + } + }); + + server.setRequestHandler(GetTaskRequestSchema, async (request) => { + const taskId = request.params?.taskId as string; + const task = tasks.get(taskId); + if (!task) { + throw new Error(`Unknown task: ${taskId}`); + } + + return taskView(task); + }); + + server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => { + const taskId = request.params?.taskId as string; + const task = tasks.get(taskId); + if (!task) { + throw new Error(`Unknown task: ${taskId}`); + } + + if (task.status === 'input_required') { + return { + resultType: 'input_required', + inputRequests: task.inputRequests + }; + } + + if (task.status === 'completed') { + return { + content: [ + { + type: 'text', + text: task.finalContent ?? 'Task completed' + } + ] + }; + } + + return { status: task.status }; + }); + + server.setRequestHandler(TasksInputResponseRequestSchema, async (request) => { + const params = request.params as Record; + const meta = params._meta as Record | undefined; + const relatedTask = meta?.['io.modelcontextprotocol/related-task'] as + | Record + | undefined; + const taskId = relatedTask?.taskId as string | undefined; + const inputResponses = params.inputResponses as + | Record + | undefined; + + if (!taskId) { + throw new Error('Missing related task metadata'); + } + + const task = tasks.get(taskId); + if (!task) { + throw new Error(`Unknown task: ${taskId}`); + } + + const expectedKeys = Object.keys(task.inputRequests ?? {}); + const providedKeys = Object.keys(inputResponses ?? {}); + const hasAllExpected = + expectedKeys.length > 0 && + expectedKeys.every((key) => providedKeys.includes(key)); + + if (!hasAllExpected) { + updateTask(task, { status: 'input_required' }); + return ackResult(taskId); + } + + if (task.kind === 'multi' && task.inputRound === 0) { + task.inputRound = 1; + updateTask(task, { + status: 'input_required', + inputRequests: { + second_input: { + method: 'elicitation/create', + params: { + message: 'Second input needed', + requestedSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] + } + } + } + } + }); + + return { + resultType: 'input_required', + inputRequests: task.inputRequests, + _meta: { + 'io.modelcontextprotocol/related-task': { taskId } + } + }; + } + + if (task.kind === 'basic') { + const userInput = getInputText(inputResponses?.['user_input'], 'input'); + updateTask(task, { + status: 'completed', + finalContent: `Task completed with input: ${userInput}` + }); + } else { + const finalInput = getInputText( + inputResponses?.['second_input'], + 'input' + ); + updateTask(task, { + status: 'completed', + finalContent: `Task completed after second input: ${finalInput}` + }); + } + + return ackResult(taskId); + }); + + server.setRequestHandler(CancelTaskRequestSchema, async (request) => { + const taskId = request.params?.taskId as string; + const task = tasks.get(taskId); + if (!task) { + throw new Error(`Unknown task: ${taskId}`); + } + + updateTask(task, { status: 'cancelled' }); + return { acknowledged: true }; + }); + + return server; +} + +const app = express(); +app.use(express.json()); + +const sessionTransports: { + [sessionId: string]: StreamableHTTPServerTransport; +} = {}; +const sessionServers: { [sessionId: string]: Server } = {}; + +function isInitializeRequest(body: unknown): boolean { + if (Array.isArray(body)) { + return body.some( + (msg: Record) => msg.method === 'initialize' + ); + } + return (body as Record)?.method === 'initialize'; +} + +app.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && sessionTransports[sessionId]) { + await sessionTransports[sessionId].handleRequest(req, res, req.body); + return; + } + + if (!sessionId && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + const server = createServer(); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + onsessioninitialized: (sid: string) => { + sessionTransports[sid] = transport; + sessionServers[sid] = server; + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) { + delete sessionTransports[sid]; + delete sessionServers[sid]; + } + }; + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session' }, + id: null + }); +}); + +app.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && sessionTransports[sessionId]) { + await sessionTransports[sessionId].handleRequest(req, res); + return; + } + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session' }, + id: null + }); +}); + +app.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId && sessionTransports[sessionId]) { + await sessionTransports[sessionId].handleRequest(req, res); + return; + } + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session' }, + id: null + }); +}); + +const PORT = parseInt(process.env.PORT || '3010', 10); +app.listen(PORT, () => { + console.log( + `SEP-2322 MRTR reference server running on http://localhost:${PORT}/mcp` + ); +}); diff --git a/examples/servers/typescript/sep-2322-no-mrtr.ts b/examples/servers/typescript/sep-2322-no-mrtr.ts new file mode 100644 index 00000000..28583d0e --- /dev/null +++ b/examples/servers/typescript/sep-2322-no-mrtr.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env node + +/** + * SEP-2322 Negative Test Server + * + * This server advertises the same tools as the MRTR reference server but + * returns normal complete results instead of InputRequiredResult. This lets + * negative tests verify that the conformance checks correctly emit FAILURE + * when a server doesn't actually implement the MRTR flow. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + StreamableHTTPServerTransport, + EventStore, + EventId, + StreamId +} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema +} from '@modelcontextprotocol/sdk/types.js'; +import express from 'express'; +import { randomUUID } from 'crypto'; + +// ─── In-Memory Event Store for SSE ────────────────────────────────────────── + +class InMemoryEventStore implements EventStore { + private events: Map = + new Map(); + private counter = 0; + + async storeEvent( + streamId: StreamId, + message: string + ): Promise { + const id = String(++this.counter); + this.events.set(id, { streamId: streamId as string, message }); + return id as EventId; + } + + async replayEventsAfter( + lastEventId: EventId, + { + send + }: { send: (eventId: EventId, message: string) => void } + ): Promise { + const start = parseInt(lastEventId as string, 10) || 0; + for (const [id, evt] of this.events) { + if (parseInt(id, 10) > start) { + send(id as EventId, evt.message); + } + } + return (this.events.size > 0 + ? String(this.counter) + : (lastEventId as string)) as string; + } +} + +function createServer(): Server { + const server = new Server( + { name: 'sep-2322-no-mrtr', version: '1.0.0' }, + { + capabilities: { + tools: {}, + prompts: {} + } + } + ); + + // ─── Tools: list ──────────────────────────────────────────────────── + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test_input_required_result_elicitation', + description: 'Returns a normal result (no MRTR)', + inputSchema: { type: 'object' as const, properties: {} } + } + ] + })); + + // ─── Tools: call — always returns a complete result ───────────────── + server.setRequestHandler(CallToolRequestSchema, async () => ({ + content: [{ type: 'text', text: 'Done (no input required)' }] + })); + + // ─── Prompts: list ────────────────────────────────────────────────── + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [ + { + name: 'test_input_required_result_prompt', + description: 'Returns a normal prompt result (no MRTR)' + } + ] + })); + + // ─── Prompts: get — always returns a complete result ──────────────── + server.setRequestHandler(GetPromptRequestSchema, async () => ({ + messages: [ + { + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Normal response, no MRTR.' } + } + ] + })); + + return server; +} + +// ─── HTTP transport ──────────────────────────────────────────────────────── + +const PORT = parseInt(process.env.PORT || '3011', 10); +const app = express(); +app.use(express.json()); + +const transports = new Map(); + +app.all('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && transports.has(sessionId)) { + const transport = transports.get(sessionId)!; + await transport.handleRequest(req, res); + return; + } + + if (req.method === 'POST') { + const eventStore = new InMemoryEventStore(); + const server = createServer(); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + onsessioninitialized: (sid) => { + transports.set(sid, transport); + } + }); + + transport.onclose = () => { + const sid = (transport as unknown as { sessionId?: string }).sessionId; + if (sid) transports.delete(sid); + }; + + await server.connect(transport); + await transport.handleRequest(req, res); + } else { + res.status(400).json({ error: 'No valid session' }); + } +}); + +app.listen(PORT, () => { + console.log(`sep-2322-no-mrtr server running on http://localhost:${PORT}/mcp`); +}); diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 4e22a4f5..5cc4f872 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -64,23 +64,23 @@ import { import { DNSRebindingProtectionScenario } from './server/dns-rebinding'; -// IncompleteResult scenarios from (SEP-2322) +// InputRequiredResult scenarios from (SEP-2322) import { - IncompleteResultBasicElicitationScenario, - IncompleteResultBasicSamplingScenario, - IncompleteResultBasicListRootsScenario, - IncompleteResultRequestStateScenario, - IncompleteResultMultipleInputRequestsScenario, - IncompleteResultMultiRoundScenario, - IncompleteResultMissingInputResponseScenario, - IncompleteResultNonToolRequestScenario -} from './server/incomplete-result'; + InputRequiredResultBasicElicitationScenario, + InputRequiredResultBasicSamplingScenario, + InputRequiredResultBasicListRootsScenario, + InputRequiredResultRequestStateScenario, + InputRequiredResultMultipleInputRequestsScenario, + InputRequiredResultMultiRoundScenario, + InputRequiredResultMissingInputResponseScenario, + InputRequiredResultNonToolRequestScenario +} from './server/input-required-result'; import { - IncompleteResultTaskBasicScenario, - IncompleteResultTaskBadInputResponseScenario, - IncompleteResultTaskInputResponseIncompleteScenario -} from './server/incomplete-result-tasks'; + InputRequiredResultTaskBasicScenario, + InputRequiredResultTaskBadInputResponseScenario, + InputRequiredResultTaskInputResponseInputRequiredScenario +} from './server/input-required-result-tasks'; import { authScenariosList, @@ -102,20 +102,21 @@ const pendingClientScenariosList: ClientScenario[] = [ // https://github.com/modelcontextprotocol/typescript-sdk/pull/1129 new ServerSSEPollingScenario(), - // IncompleteResult scenarios (SEP-2322) — pending until a conformance test - // server implements IncompleteResult tools. These are draft spec scenarios - // intended to be run via `--spec-version draft` against capable servers. - new IncompleteResultBasicElicitationScenario(), - new IncompleteResultBasicSamplingScenario(), - new IncompleteResultBasicListRootsScenario(), - new IncompleteResultRequestStateScenario(), - new IncompleteResultMultipleInputRequestsScenario(), - new IncompleteResultMultiRoundScenario(), - new IncompleteResultMissingInputResponseScenario(), - new IncompleteResultNonToolRequestScenario(), - new IncompleteResultTaskBasicScenario(), - new IncompleteResultTaskBadInputResponseScenario(), - new IncompleteResultTaskInputResponseIncompleteScenario() + // InputRequiredResult scenarios (SEP-2322) — pending in the everything-server + // because McpServer.registerTool cannot return resultType: "input_required". + // These are tested against the dedicated sep-2322-mrtr-server instead. + new InputRequiredResultBasicElicitationScenario(), + new InputRequiredResultBasicSamplingScenario(), + new InputRequiredResultBasicListRootsScenario(), + new InputRequiredResultRequestStateScenario(), + new InputRequiredResultMultipleInputRequestsScenario(), + new InputRequiredResultMultiRoundScenario(), + new InputRequiredResultMissingInputResponseScenario(), + new InputRequiredResultNonToolRequestScenario(), + new InputRequiredResultTaskBasicScenario(), + new InputRequiredResultTaskBadInputResponseScenario(), + new InputRequiredResultTaskInputResponseInputRequiredScenario() + ]; // All client scenarios @@ -175,20 +176,20 @@ const allClientScenariosList: ClientScenario[] = [ // Security scenarios new DNSRebindingProtectionScenario(), - // IncompleteResult scenarios (SEP-2322) - new IncompleteResultBasicElicitationScenario(), - new IncompleteResultBasicSamplingScenario(), - new IncompleteResultBasicListRootsScenario(), - new IncompleteResultRequestStateScenario(), - new IncompleteResultMultipleInputRequestsScenario(), - new IncompleteResultMultiRoundScenario(), - new IncompleteResultMissingInputResponseScenario(), - new IncompleteResultNonToolRequestScenario(), - - // IncompleteResult Task scenarios (SEP-2322) - new IncompleteResultTaskBasicScenario(), - new IncompleteResultTaskBadInputResponseScenario(), - new IncompleteResultTaskInputResponseIncompleteScenario() + // InputRequiredResult scenarios (SEP-2322) + new InputRequiredResultBasicElicitationScenario(), + new InputRequiredResultBasicSamplingScenario(), + new InputRequiredResultBasicListRootsScenario(), + new InputRequiredResultRequestStateScenario(), + new InputRequiredResultMultipleInputRequestsScenario(), + new InputRequiredResultMultiRoundScenario(), + new InputRequiredResultMissingInputResponseScenario(), + new InputRequiredResultNonToolRequestScenario(), + + // InputRequiredResult Task scenarios (SEP-2322) + new InputRequiredResultTaskBasicScenario(), + new InputRequiredResultTaskBadInputResponseScenario(), + new InputRequiredResultTaskInputResponseInputRequiredScenario() ]; // Active client scenarios (excludes pending) diff --git a/src/scenarios/server/client-helper.ts b/src/scenarios/server/client-helper.ts index 5e99374c..95d8e949 100644 --- a/src/scenarios/server/client-helper.ts +++ b/src/scenarios/server/client-helper.ts @@ -83,6 +83,7 @@ export class RawMcpSession { private nextId = 1; private serverUrl: string; private connection: MCPClientConnection | null = null; + private sessionId: string | undefined = undefined; constructor(serverUrl: string) { this.serverUrl = serverUrl; @@ -94,6 +95,7 @@ export class RawMcpSession { */ async initialize(): Promise { this.connection = await connectToServer(this.serverUrl); + this.sessionId = this.connection.transport.sessionId; } /** @@ -112,6 +114,10 @@ export class RawMcpSession { Accept: 'application/json, text/event-stream' }; + if (this.sessionId) { + headers['Mcp-Session-Id'] = this.sessionId; + } + const body = JSON.stringify({ jsonrpc: '2.0', id, diff --git a/src/scenarios/server/incomplete-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts similarity index 72% rename from src/scenarios/server/incomplete-result-helpers.ts rename to src/scenarios/server/input-required-result-helpers.ts index e79189c0..b78e1616 100644 --- a/src/scenarios/server/incomplete-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -2,7 +2,7 @@ * Helpers for SEP-2322 conformance tests. * * Uses RawMcpSession from client-helper.ts for connection management and - * raw JSON-RPC transport. This file adds IncompleteResult-specific type + * raw JSON-RPC transport. This file adds InputRequiredResult-specific type * guards and mock response builders. */ @@ -10,10 +10,10 @@ import { RawMcpSession, JsonRpcResponse } from './client-helper'; export type { RawMcpSession, JsonRpcResponse }; -// ─── IncompleteResult Types ────────────────────────────────────────────────── +// ─── InputRequiredResult Types ─────────────────────────────────────────────── -export interface IncompleteResult { - result_type?: 'incomplete'; +export interface InputRequiredResultData { + resultType?: 'input_required'; inputRequests?: Record; requestState?: string; _meta?: Record; @@ -28,35 +28,35 @@ export interface InputRequestObject { // ─── Type Guards ───────────────────────────────────────────────────────────── /** - * Check if a JSON-RPC result is an IncompleteResult. + * Check if a JSON-RPC result is an InputRequiredResult. */ -export function isIncompleteResult( +export function isInputRequiredResult( result: Record | undefined -): result is IncompleteResult { +): result is InputRequiredResultData { if (!result) return false; - if (result.result_type === 'incomplete') return true; - // Also detect by presence of IncompleteResult fields + if (result.resultType === 'input_required') return true; + // Also detect by presence of InputRequiredResult fields return 'inputRequests' in result || 'requestState' in result; } /** - * Check if a JSON-RPC result is a complete result (not incomplete). - * complete is the default so if result_type is missing we assume it's complete. + * 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.result_type === 'complete') return true; - if (!('result_type' in result)) return true; - return !isIncompleteResult(result); + if (result.resultType === 'complete') return true; + if (!('resultType' in result)) return true; + return !isInputRequiredResult(result); } /** - * Extract inputRequests from an IncompleteResult. + * Extract inputRequests from an InputRequiredResult. */ export function getInputRequests( - result: IncompleteResult + result: InputRequiredResultData ): Record | undefined { return result.inputRequests; } @@ -107,7 +107,7 @@ export function mockListRootsResponse(): Record { // ─── Spec References ───────────────────────────────────────────────────────── /** - * SEP reference for IncompleteResult / MRTR tests. + * SEP reference for InputRequiredResult / MRTR tests. */ export const MRTR_SPEC_REFERENCES = [ { diff --git a/src/scenarios/server/incomplete-result-tasks.ts b/src/scenarios/server/input-required-result-tasks.ts similarity index 80% rename from src/scenarios/server/incomplete-result-tasks.ts rename to src/scenarios/server/input-required-result-tasks.ts index c2c94883..d561fe17 100644 --- a/src/scenarios/server/incomplete-result-tasks.ts +++ b/src/scenarios/server/input-required-result-tasks.ts @@ -9,12 +9,12 @@ import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { createRawSession } from './client-helper'; import { - isIncompleteResult, + isInputRequiredResult, isCompleteResult, mockElicitResponse, MRTR_SPEC_REFERENCES, RawMcpSession -} from './incomplete-result-helpers'; +} from './input-required-result-helpers'; /** * Poll tasks/get until the task reaches the expected status or times out. @@ -47,23 +47,23 @@ async function pollTaskStatus( // ─── B1: Basic Persistent Workflow ─────────────────────────────────────────── -export class IncompleteResultTaskBasicScenario implements ClientScenario { - name = 'incomplete-result-task-basic'; +export class InputRequiredResultTaskBasicScenario implements ClientScenario { + name = 'input-required-result-task-basic'; specVersions: SpecVersion[] = ['draft']; - description = `Test full persistent IncompleteResult workflow via Tasks API (SEP-2322). + description = `Test full persistent InputRequiredResult workflow via Tasks API (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_task\` that supports task-augmented execution. +Implement a tool named \`test_input_required_result_task\` that supports task-augmented execution. **Behavior:** 1. When called with \`task\` metadata, return a \`CreateTaskResult\` with \`status: "working"\` 2. After a brief period, set task status to \`"input_required"\` -3. When \`tasks/result\` is called, return an \`IncompleteResult\` with \`inputRequests\`: +3. When \`tasks/result\` is called, return an \`InputRequiredResult\` with \`inputRequests\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "user_input": { "method": "elicitation/create", @@ -92,7 +92,7 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen // Step 1: Call tool with task metadata const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_task', + name: 'test_input_required_result_task', arguments: {}, task: { ttl: 30000 } }); @@ -122,8 +122,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen } checks.push({ - id: 'incomplete-result-task-created', - name: 'IncompleteResultTaskCreated', + id: 'input-required-result-task-created', + name: 'InputRequiredResultTaskCreated', description: 'Server creates task with working status', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -149,8 +149,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen } checks.push({ - id: 'incomplete-result-task-input-required', - name: 'IncompleteResultTaskInputRequired', + id: 'input-required-result-task-input-required', + name: 'InputRequiredResultTaskInputRequired', description: 'Task reaches input_required status', status: pollErrors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -170,20 +170,20 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen r3Errors.push(`JSON-RPC error: ${r3.error.message}`); } else if (!r3Result) { r3Errors.push('No result from tasks/result'); - } else if (!isIncompleteResult(r3Result)) { + } else if (!isInputRequiredResult(r3Result)) { r3Errors.push( - 'Expected IncompleteResult with inputRequests from tasks/result' + 'Expected InputRequiredResult with inputRequests from tasks/result' ); } else if (!r3Result.inputRequests) { r3Errors.push( - 'IncompleteResult from tasks/result missing inputRequests' + 'InputRequiredResult from tasks/result missing inputRequests' ); } checks.push({ - id: 'incomplete-result-task-tasks-result-incomplete', - name: 'IncompleteResultTaskTasksResultIncomplete', - description: 'tasks/result returns IncompleteResult with inputRequests', + id: 'input-required-result-task-tasks-result-incomplete', + name: 'InputRequiredResultTaskTasksResultIncomplete', + description: 'tasks/result returns InputRequiredResult with inputRequests', status: r3Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r3Errors.length > 0 ? r3Errors.join('; ') : undefined, @@ -191,7 +191,7 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen details: { result: r3Result } }); - if (r3Errors.length > 0 || !isIncompleteResult(r3Result)) return checks; + if (r3Errors.length > 0 || !isInputRequiredResult(r3Result)) return checks; // Step 4: Call tasks/input_response with inputResponses const inputKey = Object.keys(r3Result.inputRequests!)[0]; @@ -210,8 +210,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen } checks.push({ - id: 'incomplete-result-task-input-response-sent', - name: 'IncompleteResultTaskInputResponseSent', + id: 'input-required-result-task-input-response-sent', + name: 'InputRequiredResultTaskInputResponseSent', description: 'tasks/input_response is acknowledged by server', status: r4Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -244,8 +244,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen } checks.push({ - id: 'incomplete-result-task-ack-structure', - name: 'IncompleteResultTaskAckStructure', + id: 'input-required-result-task-ack-structure', + name: 'InputRequiredResultTaskAckStructure', description: 'tasks/input_response acknowledgment includes task metadata', status: ackErrors.length === 0 ? 'SUCCESS' : 'WARNING', @@ -271,8 +271,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen } checks.push({ - id: 'incomplete-result-task-completed', - name: 'IncompleteResultTaskCompleted', + id: 'input-required-result-task-completed', + name: 'InputRequiredResultTaskCompleted', description: 'Task reaches completed status after input_response', status: compErrors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -299,8 +299,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen } checks.push({ - id: 'incomplete-result-task-final-result', - name: 'IncompleteResultTaskFinalResult', + id: 'input-required-result-task-final-result', + name: 'InputRequiredResultTaskFinalResult', description: 'tasks/result returns complete final result', status: r6Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -310,8 +310,8 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen }); } catch (error) { checks.push({ - id: 'incomplete-result-task-created', - name: 'IncompleteResultTaskCreated', + id: 'input-required-result-task-created', + name: 'InputRequiredResultTaskCreated', description: 'Server creates task with working status', status: 'FAILURE', timestamp: new Date().toISOString(), @@ -326,16 +326,16 @@ Implement a tool named \`test_incomplete_result_task\` that supports task-augmen // ─── B2: Bad Input Response ────────────────────────────────────────────────── -export class IncompleteResultTaskBadInputResponseScenario +export class InputRequiredResultTaskBadInputResponseScenario implements ClientScenario { - name = 'incomplete-result-task-bad-input-response'; + name = 'input-required-result-task-bad-input-response'; specVersions: SpecVersion[] = ['draft']; description = `Test error handling when tasks/input_response contains wrong data (SEP-2322). **Server Implementation Requirements:** -Use the same tool as B1: \`test_incomplete_result_task\`. +Use the same tool as B1: \`test_input_required_result_task\`. **Behavior:** When the client sends \`tasks/input_response\` with incorrect keys, the server SHOULD acknowledge the message but keep the task in \`input_required\` status. The next \`tasks/result\` call should return a new \`inputRequests\` re-requesting the needed information.`; @@ -347,7 +347,7 @@ Use the same tool as B1: \`test_incomplete_result_task\`. // Create task and wait for input_required const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_task', + name: 'test_input_required_result_task', arguments: {}, task: { ttl: 30000 } }); @@ -355,8 +355,8 @@ Use the same tool as B1: \`test_incomplete_result_task\`. const task = r1.result?.task as { taskId?: string } | undefined; if (!task?.taskId) { checks.push({ - id: 'incomplete-result-task-bad-input-prereq', - name: 'IncompleteResultTaskBadInputPrereq', + id: 'input-required-result-task-bad-input-prereq', + name: 'InputRequiredResultTaskBadInputPrereq', description: 'Prerequisite: Task creation', status: 'FAILURE', timestamp: new Date().toISOString(), @@ -374,12 +374,12 @@ Use the same tool as B1: \`test_incomplete_result_task\`. if ( r3.error || !r3.result || - !isIncompleteResult(r3.result) || + !isInputRequiredResult(r3.result) || !r3.result.inputRequests ) { checks.push({ - id: 'incomplete-result-task-bad-input-prereq', - name: 'IncompleteResultTaskBadInputPrereq', + id: 'input-required-result-task-bad-input-prereq', + name: 'InputRequiredResultTaskBadInputPrereq', description: 'Prerequisite: Get inputRequests', status: 'FAILURE', timestamp: new Date().toISOString(), @@ -417,7 +417,7 @@ Use the same tool as B1: \`test_incomplete_result_task\`. const r5 = await session.send('tasks/result', { taskId }); if ( r5.result && - isIncompleteResult(r5.result) && + isInputRequiredResult(r5.result) && r5.result.inputRequests ) { newInputRequests = true; @@ -437,8 +437,8 @@ Use the same tool as B1: \`test_incomplete_result_task\`. } checks.push({ - id: 'incomplete-result-task-bad-input-rerequests', - name: 'IncompleteResultTaskBadInputRerequests', + id: 'input-required-result-task-bad-input-rerequests', + name: 'InputRequiredResultTaskBadInputRerequests', description: 'Server keeps task in input_required and re-requests after bad inputResponses', status: @@ -461,8 +461,8 @@ Use the same tool as B1: \`test_incomplete_result_task\`. }); } catch (error) { checks.push({ - id: 'incomplete-result-task-bad-input-rerequests', - name: 'IncompleteResultTaskBadInputRerequests', + id: 'input-required-result-task-bad-input-rerequests', + name: 'InputRequiredResultTaskBadInputRerequests', description: 'Server keeps task in input_required and re-requests after bad inputResponses', status: 'FAILURE', @@ -476,27 +476,27 @@ Use the same tool as B1: \`test_incomplete_result_task\`. } } -// ─── B4: tasks/input_response returning IncompleteResult ───────────────────── +// ─── B4: tasks/input_response returning InputRequiredResult ───────────────────── -export class IncompleteResultTaskInputResponseIncompleteScenario +export class InputRequiredResultTaskInputResponseInputRequiredScenario implements ClientScenario { - name = 'incomplete-result-task-input-response-incomplete'; + name = 'input-required-result-task-input-response-incomplete'; specVersions: SpecVersion[] = ['draft']; - description = `Test that tasks/input_response can itself return an IncompleteResult (SEP-2322). + description = `Test that tasks/input_response can itself return an InputRequiredResult (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_task_multi_input\` that supports task-augmented execution and requires TWO rounds of input. +Implement a tool named \`test_input_required_result_task_multi_input\` that supports task-augmented execution and requires TWO rounds of input. **Behavior:** 1. Create task, transition to \`input_required\` -2. \`tasks/result\` returns IncompleteResult with first \`inputRequests\` -3. \`tasks/input_response\` returns an \`IncompleteResult\` with ADDITIONAL \`inputRequests\` +2. \`tasks/result\` returns InputRequiredResult with first \`inputRequests\` +3. \`tasks/input_response\` returns an \`InputRequiredResult\` with ADDITIONAL \`inputRequests\` 4. Client sends another \`tasks/input_response\` with the additional responses 5. Task completes -This tests the schema: \`TaskInputResponseResultResponse.result: Result | IncompleteResult\``; +This tests the schema: \`TaskInputResponseResultResponse.result: Result | InputRequiredResult\``; async run(serverUrl: string): Promise { const checks: ConformanceCheck[] = []; @@ -506,7 +506,7 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp // Create task const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_task_multi_input', + name: 'test_input_required_result_task_multi_input', arguments: {}, task: { ttl: 30000 } }); @@ -514,8 +514,8 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp const task = r1.result?.task as { taskId?: string } | undefined; if (!task?.taskId) { checks.push({ - id: 'incomplete-result-task-multi-input-prereq', - name: 'IncompleteResultTaskMultiInputPrereq', + id: 'input-required-result-task-multi-input-prereq', + name: 'InputRequiredResultTaskMultiInputPrereq', description: 'Prerequisite: Task creation', status: 'FAILURE', timestamp: new Date().toISOString(), @@ -533,12 +533,12 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp if ( r3.error || !r3.result || - !isIncompleteResult(r3.result) || + !isInputRequiredResult(r3.result) || !r3.result.inputRequests ) { checks.push({ - id: 'incomplete-result-task-multi-input-prereq', - name: 'IncompleteResultTaskMultiInputPrereq', + id: 'input-required-result-task-multi-input-prereq', + name: 'InputRequiredResultTaskMultiInputPrereq', description: 'Prerequisite: Get first inputRequests', status: 'FAILURE', timestamp: new Date().toISOString(), @@ -548,7 +548,7 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp return checks; } - // Send first input_response — expect IncompleteResult back + // Send first input_response — expect InputRequiredResult back const inputKey1 = Object.keys(r3.result.inputRequests!)[0]; const r4 = await session.send('tasks/input_response', { inputResponses: { @@ -566,21 +566,21 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp r4Errors.push(`JSON-RPC error: ${r4.error.message}`); } else if (!r4Result) { r4Errors.push('No result from tasks/input_response'); - } else if (!isIncompleteResult(r4Result)) { + } else if (!isInputRequiredResult(r4Result)) { r4Errors.push( - 'Expected IncompleteResult from tasks/input_response (additional input needed)' + 'Expected InputRequiredResult from tasks/input_response (additional input needed)' ); } else if (!r4Result.inputRequests) { r4Errors.push( - 'IncompleteResult from tasks/input_response missing inputRequests' + 'InputRequiredResult from tasks/input_response missing inputRequests' ); } checks.push({ - id: 'incomplete-result-task-input-response-returns-incomplete', - name: 'IncompleteResultTaskInputResponseReturnsIncomplete', + id: 'input-required-result-task-input-response-returns-incomplete', + name: 'InputRequiredResultTaskInputResponseReturnsIncomplete', description: - 'tasks/input_response returns IncompleteResult with additional inputRequests', + 'tasks/input_response returns InputRequiredResult with additional inputRequests', status: r4Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r4Errors.length > 0 ? r4Errors.join('; ') : undefined, @@ -589,7 +589,7 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp }); // Send second input_response — expect completion - if (r4Errors.length === 0 && isIncompleteResult(r4Result)) { + if (r4Errors.length === 0 && isInputRequiredResult(r4Result)) { const inputKey2 = Object.keys(r4Result.inputRequests!)[0]; const r5 = await session.send('tasks/input_response', { inputResponses: { @@ -607,8 +607,8 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp const finalState = await pollTaskStatus(session, taskId, 'completed'); checks.push({ - id: 'incomplete-result-task-multi-input-completed', - name: 'IncompleteResultTaskMultiInputCompleted', + id: 'input-required-result-task-multi-input-completed', + name: 'InputRequiredResultTaskMultiInputCompleted', description: 'Task completes after second input_response', status: finalState?.status === 'completed' ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -623,10 +623,10 @@ This tests the schema: \`TaskInputResponseResultResponse.result: Result | Incomp } } catch (error) { checks.push({ - id: 'incomplete-result-task-input-response-returns-incomplete', - name: 'IncompleteResultTaskInputResponseReturnsIncomplete', + id: 'input-required-result-task-input-response-returns-incomplete', + name: 'InputRequiredResultTaskInputResponseReturnsIncomplete', description: - 'tasks/input_response returns IncompleteResult with additional inputRequests', + 'tasks/input_response returns InputRequiredResult with additional inputRequests', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/scenarios/server/incomplete-result.ts b/src/scenarios/server/input-required-result.ts similarity index 72% rename from src/scenarios/server/incomplete-result.ts rename to src/scenarios/server/input-required-result.ts index 7b4b63c1..2334031c 100644 --- a/src/scenarios/server/incomplete-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -1,40 +1,40 @@ /** - * SEP-2322: IncompleteResult - Ephemeral Workflow Tests + * SEP-2322: InputRequiredResult - Ephemeral Workflow Tests * * Tests the ephemeral (stateless) workflow where servers respond with - * IncompleteResult containing inputRequests and/or requestState, and + * InputRequiredResult containing inputRequests and/or requestState, and * clients retry with inputResponses and echoed requestState. */ import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; import { createRawSession } from './client-helper'; import { - isIncompleteResult, + isInputRequiredResult, isCompleteResult, mockElicitResponse, mockSamplingResponse, mockListRootsResponse, MRTR_SPEC_REFERENCES -} from './incomplete-result-helpers'; +} from './input-required-result-helpers'; // ─── A1: Basic Elicitation ──────────────────────────────────────────────────── -export class IncompleteResultBasicElicitationScenario +export class InputRequiredResultBasicElicitationScenario implements ClientScenario { - name = 'incomplete-result-basic-elicitation'; + name = 'input-required-result-basic-elicitation'; specVersions: SpecVersion[] = ['draft']; - description = `Test basic ephemeral IncompleteResult flow with a single elicitation input request (SEP-2322). + description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_tool_with_elicitation\` (no arguments required). +Implement a tool named \`test_input_required_result_elicitation\` (no arguments required). -**Behavior (Round 1):** When called without \`inputResponses\`, return an \`IncompleteResult\`: +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "user_name": { "method": "elicitation/create", @@ -67,9 +67,9 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). try { const session = await createRawSession(serverUrl); - // Round 1: Initial call — expect IncompleteResult + // Round 1: Initial call — expect InputRequiredResult const r1 = await session.send('tools/call', { - name: 'test_tool_with_elicitation', + name: 'test_input_required_result_elicitation', arguments: {} }); @@ -80,14 +80,14 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). r1Errors.push(`JSON-RPC error: ${r1.error.message}`); } else if (!r1Result) { r1Errors.push('No result in response'); - } else if (!isIncompleteResult(r1Result)) { + } else if (!isInputRequiredResult(r1Result)) { r1Errors.push( - 'Expected IncompleteResult but got a complete result. ' + - 'Server should return result_type: "incomplete" with inputRequests.' + 'Expected InputRequiredResult but got a complete result. ' + + 'Server should return resultType: "input_required" with inputRequests.' ); } else { if (!r1Result.inputRequests) { - r1Errors.push('IncompleteResult missing inputRequests'); + r1Errors.push('InputRequiredResult missing inputRequests'); } else if (!r1Result.inputRequests['user_name']) { r1Errors.push('inputRequests missing expected key "user_name"'); } else { @@ -101,10 +101,10 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). } checks.push({ - id: 'incomplete-result-elicitation-incomplete', - name: 'IncompleteResultElicitationIncomplete', + id: 'input-required-result-elicitation-incomplete', + name: 'InputRequiredResultElicitationIncomplete', description: - 'Server returns IncompleteResult with elicitation inputRequest', + 'Server returns InputRequiredResult with elicitation inputRequest', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, @@ -113,9 +113,9 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). }); // Round 2: Retry with inputResponses — expect complete result - if (r1Errors.length === 0 && isIncompleteResult(r1Result)) { + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const r2 = await session.send('tools/call', { - name: 'test_tool_with_elicitation', + name: 'test_input_required_result_elicitation', arguments: {}, inputResponses: { user_name: mockElicitResponse({ name: 'Alice' }) @@ -146,8 +146,8 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). } checks.push({ - id: 'incomplete-result-elicitation-complete', - name: 'IncompleteResultElicitationComplete', + id: 'input-required-result-elicitation-complete', + name: 'InputRequiredResultElicitationComplete', description: 'Server returns complete result after retry with inputResponses', status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -159,10 +159,10 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). } } catch (error) { checks.push({ - id: 'incomplete-result-elicitation-incomplete', - name: 'IncompleteResultElicitationIncomplete', + id: 'input-required-result-elicitation-incomplete', + name: 'InputRequiredResultElicitationIncomplete', description: - 'Server returns IncompleteResult with elicitation inputRequest', + 'Server returns InputRequiredResult with elicitation inputRequest', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -176,20 +176,20 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). // ─── A2: Basic Sampling ────────────────────────────────────────────────────── -export class IncompleteResultBasicSamplingScenario implements ClientScenario { - name = 'incomplete-result-basic-sampling'; +export class InputRequiredResultBasicSamplingScenario implements ClientScenario { + name = 'input-required-result-basic-sampling'; specVersions: SpecVersion[] = ['draft']; - description = `Test basic ephemeral IncompleteResult flow with a single sampling input request (SEP-2322). + description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_sampling\` (no arguments required). +Implement a tool named \`test_input_required_result_sampling\` (no arguments required). -**Behavior (Round 1):** When called without \`inputResponses\`, return an \`IncompleteResult\`: +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "capital_question": { "method": "sampling/createMessage", @@ -215,7 +215,7 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require // Round 1: Initial call const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_sampling', + name: 'test_input_required_result_sampling', arguments: {} }); @@ -226,11 +226,11 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require r1Errors.push(`JSON-RPC error: ${r1.error.message}`); } else if (!r1Result) { r1Errors.push('No result in response'); - } else if (!isIncompleteResult(r1Result)) { - r1Errors.push('Expected IncompleteResult with sampling inputRequest'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult with sampling inputRequest'); } else { if (!r1Result.inputRequests) { - r1Errors.push('IncompleteResult missing inputRequests'); + r1Errors.push('InputRequiredResult missing inputRequests'); } else { const key = Object.keys(r1Result.inputRequests)[0]; if (!key) { @@ -247,10 +247,10 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require } checks.push({ - id: 'incomplete-result-sampling-incomplete', - name: 'IncompleteResultSamplingIncomplete', + id: 'input-required-result-sampling-incomplete', + name: 'InputRequiredResultSamplingIncomplete', description: - 'Server returns IncompleteResult with sampling inputRequest', + 'Server returns InputRequiredResult with sampling inputRequest', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, @@ -259,10 +259,10 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require }); // Round 2: Retry with inputResponses - if (r1Errors.length === 0 && isIncompleteResult(r1Result)) { + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; const r2 = await session.send('tools/call', { - name: 'test_incomplete_result_sampling', + name: 'test_input_required_result_sampling', arguments: {}, inputResponses: { [inputKey]: mockSamplingResponse('The capital of France is Paris.') @@ -286,8 +286,8 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require } checks.push({ - id: 'incomplete-result-sampling-complete', - name: 'IncompleteResultSamplingComplete', + id: 'input-required-result-sampling-complete', + name: 'InputRequiredResultSamplingComplete', description: 'Server returns complete result after retry with sampling response', status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -299,10 +299,10 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require } } catch (error) { checks.push({ - id: 'incomplete-result-sampling-incomplete', - name: 'IncompleteResultSamplingIncomplete', + id: 'input-required-result-sampling-incomplete', + name: 'InputRequiredResultSamplingIncomplete', description: - 'Server returns IncompleteResult with sampling inputRequest', + 'Server returns InputRequiredResult with sampling inputRequest', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -316,20 +316,20 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require // ─── A3: Basic ListRoots ───────────────────────────────────────────────────── -export class IncompleteResultBasicListRootsScenario implements ClientScenario { - name = 'incomplete-result-basic-list-roots'; +export class InputRequiredResultBasicListRootsScenario implements ClientScenario { + name = 'input-required-result-basic-list-roots'; specVersions: SpecVersion[] = ['draft']; - description = `Test basic ephemeral IncompleteResult flow with a single roots/list input request (SEP-2322). + description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_list_roots\` (no arguments required). +Implement a tool named \`test_input_required_result_list_roots\` (no arguments required). -**Behavior (Round 1):** When called without \`inputResponses\`, return an \`IncompleteResult\`: +**Behavior (Round 1):** When called without \`inputResponses\`, return an \`InputRequiredResult\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "client_roots": { "method": "roots/list", @@ -349,7 +349,7 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi // Round 1: Initial call const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_list_roots', + name: 'test_input_required_result_list_roots', arguments: {} }); @@ -360,11 +360,11 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi r1Errors.push(`JSON-RPC error: ${r1.error.message}`); } else if (!r1Result) { r1Errors.push('No result in response'); - } else if (!isIncompleteResult(r1Result)) { - r1Errors.push('Expected IncompleteResult with roots/list inputRequest'); + } else if (!isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult with roots/list inputRequest'); } else { if (!r1Result.inputRequests) { - r1Errors.push('IncompleteResult missing inputRequests'); + r1Errors.push('InputRequiredResult missing inputRequests'); } else { const key = Object.keys(r1Result.inputRequests)[0]; if (!key) { @@ -381,10 +381,10 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi } checks.push({ - id: 'incomplete-result-list-roots-incomplete', - name: 'IncompleteResultListRootsIncomplete', + id: 'input-required-result-list-roots-incomplete', + name: 'InputRequiredResultListRootsIncomplete', description: - 'Server returns IncompleteResult with roots/list inputRequest', + 'Server returns InputRequiredResult with roots/list inputRequest', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, @@ -393,10 +393,10 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi }); // Round 2: Retry with inputResponses - if (r1Errors.length === 0 && isIncompleteResult(r1Result)) { + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; const r2 = await session.send('tools/call', { - name: 'test_incomplete_result_list_roots', + name: 'test_input_required_result_list_roots', arguments: {}, inputResponses: { [inputKey]: mockListRootsResponse() @@ -420,8 +420,8 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi } checks.push({ - id: 'incomplete-result-list-roots-complete', - name: 'IncompleteResultListRootsComplete', + id: 'input-required-result-list-roots-complete', + name: 'InputRequiredResultListRootsComplete', description: 'Server returns complete result after retry with roots response', status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -433,10 +433,10 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi } } catch (error) { checks.push({ - id: 'incomplete-result-list-roots-incomplete', - name: 'IncompleteResultListRootsIncomplete', + id: 'input-required-result-list-roots-incomplete', + name: 'InputRequiredResultListRootsIncomplete', description: - 'Server returns IncompleteResult with roots/list inputRequest', + 'Server returns InputRequiredResult with roots/list inputRequest', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -450,20 +450,20 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi // ─── A4: Request State ────────────────────────────────────────────────────── -export class IncompleteResultRequestStateScenario implements ClientScenario { - name = 'incomplete-result-request-state'; +export class InputRequiredResultRequestStateScenario implements ClientScenario { + name = 'input-required-result-request-state'; specVersions: SpecVersion[] = ['draft']; - description = `Test that requestState is correctly round-tripped in ephemeral IncompleteResult flow (SEP-2322). + description = `Test that requestState is correctly round-tripped in ephemeral InputRequiredResult flow (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_request_state\` (no arguments required). +Implement a tool named \`test_input_required_result_request_state\` (no arguments required). -**Behavior (Round 1):** Return an \`IncompleteResult\` with both \`inputRequests\` and \`requestState\`: +**Behavior (Round 1):** Return an \`InputRequiredResult\` with both \`inputRequests\` and \`requestState\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "confirm": { "method": "elicitation/create", @@ -491,7 +491,7 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re // Round 1 const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_request_state', + name: 'test_input_required_result_request_state', arguments: {} }); @@ -500,25 +500,25 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re if (r1.error) { r1Errors.push(`JSON-RPC error: ${r1.error.message}`); - } else if (!r1Result || !isIncompleteResult(r1Result)) { - r1Errors.push('Expected IncompleteResult'); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult'); } else { if (!r1Result.requestState) { - r1Errors.push('IncompleteResult missing requestState'); + r1Errors.push('InputRequiredResult missing requestState'); } if (typeof r1Result.requestState !== 'string') { r1Errors.push('requestState must be a string'); } if (!r1Result.inputRequests) { - r1Errors.push('IncompleteResult missing inputRequests'); + r1Errors.push('InputRequiredResult missing inputRequests'); } } checks.push({ - id: 'incomplete-result-request-state-incomplete', - name: 'IncompleteResultRequestStateIncomplete', + id: 'input-required-result-request-state-incomplete', + name: 'InputRequiredResultRequestStateIncomplete', description: - 'Server returns IncompleteResult with both inputRequests and requestState', + '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, @@ -527,10 +527,10 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re }); // Round 2: Retry with inputResponses + requestState - if (r1Errors.length === 0 && isIncompleteResult(r1Result)) { + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; const r2 = await session.send('tools/call', { - name: 'test_incomplete_result_request_state', + name: 'test_input_required_result_request_state', arguments: {}, inputResponses: { [inputKey]: mockElicitResponse({ ok: true }) @@ -563,8 +563,8 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re } checks.push({ - id: 'incomplete-result-request-state-complete', - name: 'IncompleteResultRequestStateComplete', + id: 'input-required-result-request-state-complete', + name: 'InputRequiredResultRequestStateComplete', description: 'Server validates echoed requestState and returns complete result', status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -576,10 +576,10 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re } } catch (error) { checks.push({ - id: 'incomplete-result-request-state-incomplete', - name: 'IncompleteResultRequestStateIncomplete', + id: 'input-required-result-request-state-incomplete', + name: 'InputRequiredResultRequestStateIncomplete', description: - 'Server returns IncompleteResult with both inputRequests and requestState', + 'Server returns InputRequiredResult with both inputRequests and requestState', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -593,22 +593,22 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re // ─── A5: Multiple Input Requests ───────────────────────────────────────────── -export class IncompleteResultMultipleInputRequestsScenario +export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { - name = 'incomplete-result-multiple-input-requests'; + name = 'input-required-result-multiple-input-requests'; specVersions: SpecVersion[] = ['draft']; - description = `Test multiple input requests in a single IncompleteResult (SEP-2322). + description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments required). +Implement a tool named \`test_input_required_result_multiple_inputs\` (no arguments required). -**Behavior (Round 1):** Return an \`IncompleteResult\` with multiple \`inputRequests\` — elicitation, sampling, and roots/list — plus \`requestState\`: +**Behavior (Round 1):** Return an \`InputRequiredResult\` with multiple \`inputRequests\` — elicitation, sampling, and roots/list — plus \`requestState\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "user_name": { "method": "elicitation/create", @@ -647,7 +647,7 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments // Round 1 const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_multiple_inputs', + name: 'test_input_required_result_multiple_inputs', arguments: {} }); @@ -656,13 +656,13 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments if (r1.error) { r1Errors.push(`JSON-RPC error: ${r1.error.message}`); - } else if (!r1Result || !isIncompleteResult(r1Result)) { - r1Errors.push('Expected IncompleteResult'); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult'); } else if (!r1Result.inputRequests) { - r1Errors.push('IncompleteResult missing inputRequests'); + r1Errors.push('InputRequiredResult missing inputRequests'); } else { if (!r1Result.requestState) { - r1Errors.push('IncompleteResult missing requestState'); + r1Errors.push('InputRequiredResult missing requestState'); } const keys = Object.keys(r1Result.inputRequests); @@ -693,10 +693,10 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments } checks.push({ - id: 'incomplete-result-multiple-inputs-incomplete', - name: 'IncompleteResultMultipleInputsIncomplete', + id: 'input-required-result-multiple-inputs-incomplete', + name: 'InputRequiredResultMultipleInputsIncomplete', description: - 'Server returns IncompleteResult with multiple inputRequests of different types', + '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, @@ -705,7 +705,7 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments }); // Round 2: Respond to all input requests - if (r1Errors.length === 0 && isIncompleteResult(r1Result)) { + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputResponses: Record = {}; for (const [key, req] of Object.entries(r1Result.inputRequests!)) { if (req.method === 'elicitation/create') { @@ -718,7 +718,7 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments } const r2 = await session.send('tools/call', { - name: 'test_incomplete_result_multiple_inputs', + name: 'test_input_required_result_multiple_inputs', arguments: {}, inputResponses, ...(r1Result.requestState !== undefined @@ -740,8 +740,8 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments } checks.push({ - id: 'incomplete-result-multiple-inputs-complete', - name: 'IncompleteResultMultipleInputsComplete', + id: 'input-required-result-multiple-inputs-complete', + name: 'InputRequiredResultMultipleInputsComplete', description: 'Server returns complete result after all inputResponses are provided', status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -753,10 +753,10 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments } } catch (error) { checks.push({ - id: 'incomplete-result-multiple-inputs-incomplete', - name: 'IncompleteResultMultipleInputsIncomplete', + id: 'input-required-result-multiple-inputs-incomplete', + name: 'InputRequiredResultMultipleInputsIncomplete', description: - 'Server returns IncompleteResult with multiple inputRequests of different types', + 'Server returns InputRequiredResult with multiple inputRequests of different types', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -770,20 +770,20 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments // ─── A6: Multi-Round ───────────────────────────────────────────────────────── -export class IncompleteResultMultiRoundScenario implements ClientScenario { - name = 'incomplete-result-multi-round'; +export class InputRequiredResultMultiRoundScenario implements ClientScenario { + name = 'input-required-result-multi-round'; specVersions: SpecVersion[] = ['draft']; - description = `Test multi-round ephemeral IncompleteResult flow with evolving requestState (SEP-2322). + description = `Test multi-round ephemeral InputRequiredResult flow with evolving requestState (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_incomplete_result_multi_round\` (no arguments required). +Implement a tool named \`test_input_required_result_multi_round\` (no arguments required). -**Behavior (Round 1):** Return an \`IncompleteResult\` with an elicitation request and \`requestState\`: +**Behavior (Round 1):** Return an \`InputRequiredResult\` with an elicitation request and \`requestState\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "step1": { "method": "elicitation/create", @@ -801,11 +801,11 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ } \`\`\` -**Behavior (Round 2):** When called with \`inputResponses\` for step1 + requestState, return ANOTHER \`IncompleteResult\` with a new elicitation and updated requestState: +**Behavior (Round 2):** When called with \`inputResponses\` for step1 + requestState, return ANOTHER \`InputRequiredResult\` with a new elicitation and updated requestState: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "step2": { "method": "elicitation/create", @@ -833,7 +833,7 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ // Round 1 const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_multi_round', + name: 'test_input_required_result_multi_round', arguments: {} }); @@ -843,7 +843,7 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ if ( !r1.error && r1Result && - isIncompleteResult(r1Result) && + isInputRequiredResult(r1Result) && r1Result.inputRequests && r1Result.requestState ) { @@ -851,25 +851,25 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ } checks.push({ - id: 'incomplete-result-multi-round-r1', - name: 'IncompleteResultMultiRoundR1', + id: 'input-required-result-multi-round-r1', + name: 'InputRequiredResultMultiRoundR1', description: - 'Round 1: Server returns IncompleteResult with requestState', + 'Round 1: Server returns InputRequiredResult with requestState', status: round1Complete ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: round1Complete ? undefined - : 'Expected IncompleteResult with inputRequests and requestState', + : 'Expected InputRequiredResult with inputRequests and requestState', specReferences: MRTR_SPEC_REFERENCES, details: { result: r1Result } }); - if (!round1Complete || !isIncompleteResult(r1Result)) return checks; + if (!round1Complete || !isInputRequiredResult(r1Result)) return checks; - // Round 2: Retry — expect another IncompleteResult + // Round 2: Retry — expect another InputRequiredResult const r1InputKey = Object.keys(r1Result.inputRequests!)[0]; const r2 = await session.send('tools/call', { - name: 'test_incomplete_result_multi_round', + name: 'test_input_required_result_multi_round', arguments: {}, inputResponses: { [r1InputKey]: mockElicitResponse({ name: 'Alice' }) @@ -883,7 +883,7 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ if ( !r2.error && r2Result && - isIncompleteResult(r2Result) && + isInputRequiredResult(r2Result) && r2Result.inputRequests && r2Result.requestState ) { @@ -894,25 +894,25 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ } checks.push({ - id: 'incomplete-result-multi-round-r2', - name: 'IncompleteResultMultiRoundR2', + id: 'input-required-result-multi-round-r2', + name: 'InputRequiredResultMultiRoundR2', description: - 'Round 2: Server returns another IncompleteResult with updated requestState', + 'Round 2: Server returns another InputRequiredResult with updated requestState', status: round2Complete ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: round2Complete ? undefined - : 'Expected new IncompleteResult with different requestState', + : 'Expected new InputRequiredResult with different requestState', specReferences: MRTR_SPEC_REFERENCES, details: { result: r2Result } }); - if (!round2Complete || !isIncompleteResult(r2Result)) return checks; + if (!round2Complete || !isInputRequiredResult(r2Result)) return checks; // Round 3: Final retry — expect complete result const r2InputKey = Object.keys(r2Result.inputRequests!)[0]; const r3 = await session.send('tools/call', { - name: 'test_incomplete_result_multi_round', + name: 'test_input_required_result_multi_round', arguments: {}, inputResponses: { [r2InputKey]: mockElicitResponse({ color: 'blue' }) @@ -925,8 +925,8 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ !r3.error && r3Result != null && isCompleteResult(r3Result); checks.push({ - id: 'incomplete-result-multi-round-r3', - name: 'IncompleteResultMultiRoundR3', + id: 'input-required-result-multi-round-r3', + name: 'InputRequiredResultMultiRoundR3', description: 'Round 3: Server returns complete result', status: round3Complete ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), @@ -938,10 +938,10 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ }); } catch (error) { checks.push({ - id: 'incomplete-result-multi-round-r1', - name: 'IncompleteResultMultiRoundR1', + id: 'input-required-result-multi-round-r1', + name: 'InputRequiredResultMultiRoundR1', description: - 'Round 1: Server returns IncompleteResult with requestState', + 'Round 1: Server returns InputRequiredResult with requestState', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -955,18 +955,18 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ // ─── A7: Missing Input Response ────────────────────────────────────────────── -export class IncompleteResultMissingInputResponseScenario +export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { - name = 'incomplete-result-missing-input-response'; + name = 'input-required-result-missing-input-response'; specVersions: SpecVersion[] = ['draft']; description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). **Server Implementation Requirements:** -Use the same tool as A1: \`test_incomplete_result_elicitation\`. +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 \`IncompleteResult\` re-requesting the missing information (NOT a JSON-RPC error).`; +**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[] = []; @@ -976,7 +976,7 @@ Use the same tool as A1: \`test_incomplete_result_elicitation\`. // Round 1: Send wrong inputResponses (wrong key) const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_elicitation', + name: 'test_input_required_result_elicitation', arguments: {}, inputResponses: { wrong_key: mockElicitResponse({ data: 'wrong' }) @@ -989,23 +989,23 @@ Use the same tool as A1: \`test_incomplete_result_elicitation\`. 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 IncompleteResult. ' + + '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 (!isIncompleteResult(r1Result)) { + } else if (!isInputRequiredResult(r1Result)) { r1Errors.push( - 'Expected IncompleteResult re-requesting missing information, ' + + 'Expected InputRequiredResult re-requesting missing information, ' + 'but got a complete result' ); } checks.push({ - id: 'incomplete-result-missing-response-rerequests', - name: 'IncompleteResultMissingResponseRerequests', + id: 'input-required-result-missing-response-rerequests', + name: 'InputRequiredResultMissingResponseRerequests', description: - 'Server re-requests missing inputResponses via new IncompleteResult', + '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, @@ -1014,10 +1014,10 @@ Use the same tool as A1: \`test_incomplete_result_elicitation\`. }); } catch (error) { checks.push({ - id: 'incomplete-result-missing-response-rerequests', - name: 'IncompleteResultMissingResponseRerequests', + id: 'input-required-result-missing-response-rerequests', + name: 'InputRequiredResultMissingResponseRerequests', description: - 'Server re-requests missing inputResponses via new IncompleteResult', + 'Server re-requests missing inputResponses via new InputRequiredResult', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -1031,20 +1031,20 @@ Use the same tool as A1: \`test_incomplete_result_elicitation\`. // ─── A9: Non-Tool Request (prompts/get) ────────────────────────────────────── -export class IncompleteResultNonToolRequestScenario implements ClientScenario { - name = 'incomplete-result-non-tool-request'; +export class InputRequiredResultNonToolRequestScenario implements ClientScenario { + name = 'input-required-result-non-tool-request'; specVersions: SpecVersion[] = ['draft']; - description = `Test IncompleteResult on a non-tool request (prompts/get) to verify IncompleteResult is universal (SEP-2322). + 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_incomplete_result_prompt\` that requires elicitation input. +Implement a prompt named \`test_input_required_result_prompt\` that requires elicitation input. -**Behavior (Round 1):** When \`prompts/get\` is called for \`test_incomplete_result_prompt\` without \`inputResponses\`, return an \`IncompleteResult\`: +**Behavior (Round 1):** When \`prompts/get\` is called for \`test_input_required_result_prompt\` without \`inputResponses\`, return an \`InputRequiredResult\`: \`\`\`json { - "result_type": "incomplete", + "resultType": "input_required", "inputRequests": { "user_context": { "method": "elicitation/create", @@ -1071,7 +1071,7 @@ Implement a prompt named \`test_incomplete_result_prompt\` that requires elicita // Round 1 const r1 = await session.send('prompts/get', { - name: 'test_incomplete_result_prompt' + name: 'test_input_required_result_prompt' }); const r1Result = r1.result; @@ -1079,16 +1079,16 @@ Implement a prompt named \`test_incomplete_result_prompt\` that requires elicita if (r1.error) { r1Errors.push(`JSON-RPC error: ${r1.error.message}`); - } else if (!r1Result || !isIncompleteResult(r1Result)) { - r1Errors.push('Expected IncompleteResult from prompts/get'); + } else if (!r1Result || !isInputRequiredResult(r1Result)) { + r1Errors.push('Expected InputRequiredResult from prompts/get'); } else if (!r1Result.inputRequests) { - r1Errors.push('IncompleteResult missing inputRequests'); + r1Errors.push('InputRequiredResult missing inputRequests'); } checks.push({ - id: 'incomplete-result-non-tool-incomplete', - name: 'IncompleteResultNonToolIncomplete', - description: 'prompts/get returns IncompleteResult with inputRequests', + id: 'input-required-result-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, @@ -1097,10 +1097,10 @@ Implement a prompt named \`test_incomplete_result_prompt\` that requires elicita }); // Round 2: Retry with inputResponses - if (r1Errors.length === 0 && isIncompleteResult(r1Result)) { + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; const r2 = await session.send('prompts/get', { - name: 'test_incomplete_result_prompt', + name: 'test_input_required_result_prompt', inputResponses: { [inputKey]: mockElicitResponse({ context: 'test context' }) }, @@ -1125,8 +1125,8 @@ Implement a prompt named \`test_incomplete_result_prompt\` that requires elicita } checks.push({ - id: 'incomplete-result-non-tool-complete', - name: 'IncompleteResultNonToolComplete', + id: 'input-required-result-non-tool-complete', + name: 'InputRequiredResultNonToolComplete', description: 'prompts/get returns complete GetPromptResult after retry with inputResponses', status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', @@ -1138,9 +1138,9 @@ Implement a prompt named \`test_incomplete_result_prompt\` that requires elicita } } catch (error) { checks.push({ - id: 'incomplete-result-non-tool-incomplete', - name: 'IncompleteResultNonToolIncomplete', - description: 'prompts/get returns IncompleteResult with inputRequests', + id: 'input-required-result-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)}`, diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index 769374a5..b03984ab 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { DNSRebindingProtectionScenario } from './dns-rebinding'; import { ResourcesNotFoundErrorScenario } from './resources'; +import { InputRequiredResultBasicElicitationScenario } from './input-required-result'; function startServer(scriptPath: string, port: number): Promise { return new Promise((resolve, reject) => { @@ -106,4 +107,34 @@ describe('Server scenario negative tests', () => { expect(errorCode?.status).toBe('WARNING'); }, 10000); }); + + describe('sep-2322-no-mrtr', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3011; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2322-no-mrtr.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + it('emits FAILURE when server returns complete result instead of InputRequiredResult', async () => { + const scenario = new InputRequiredResultBasicElicitationScenario(); + const checks = await scenario.run(`http://localhost:${PORT}/mcp`); + + const incompleteCheck = checks.find( + (c) => c.id === 'input-required-result-elicitation-incomplete' + ); + expect(incompleteCheck).toBeDefined(); + expect(incompleteCheck?.status).toBe('FAILURE'); + }, 15000); + }); }); diff --git a/src/scenarios/server/sep-2322-mrtr.test.ts b/src/scenarios/server/sep-2322-mrtr.test.ts new file mode 100644 index 00000000..66f83640 --- /dev/null +++ b/src/scenarios/server/sep-2322-mrtr.test.ts @@ -0,0 +1,122 @@ +/** + * SEP-2322 MRTR positive tests. + * + * Runs all InputRequiredResult scenarios against the dedicated + * sep-2322-mrtr-server (which uses the low-level Server class to return + * resultType: "input_required"). + */ + +import { spawn, ChildProcess } from 'child_process'; +import path from 'path'; +import { + InputRequiredResultBasicElicitationScenario, + InputRequiredResultBasicSamplingScenario, + InputRequiredResultBasicListRootsScenario, + InputRequiredResultRequestStateScenario, + InputRequiredResultMultipleInputRequestsScenario, + InputRequiredResultMultiRoundScenario, + InputRequiredResultMissingInputResponseScenario, + InputRequiredResultNonToolRequestScenario +} from './input-required-result'; +import { + InputRequiredResultTaskBasicScenario, + InputRequiredResultTaskBadInputResponseScenario, + InputRequiredResultTaskInputResponseInputRequiredScenario +} from './input-required-result-tasks'; + +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 positive tests', () => { + let serverProcess: ChildProcess | null = null; + const PORT = 3010; + const SERVER_URL = `http://localhost:${PORT}/mcp`; + + beforeAll(async () => { + serverProcess = await startServer( + path.join( + process.cwd(), + 'examples/servers/typescript/sep-2322-mrtr-server.ts' + ), + PORT + ); + }, 35000); + + afterAll(async () => { + await stopServer(serverProcess); + }); + + const scenarios = [ + new InputRequiredResultBasicElicitationScenario(), + new InputRequiredResultBasicSamplingScenario(), + new InputRequiredResultBasicListRootsScenario(), + new InputRequiredResultRequestStateScenario(), + new InputRequiredResultMultipleInputRequestsScenario(), + new InputRequiredResultMultiRoundScenario(), + new InputRequiredResultMissingInputResponseScenario(), + new InputRequiredResultNonToolRequestScenario(), + new InputRequiredResultTaskBasicScenario(), + new InputRequiredResultTaskBadInputResponseScenario(), + new InputRequiredResultTaskInputResponseInputRequiredScenario() + ]; + + for (const scenario of scenarios) { + it(scenario.name, async () => { + const checks = await scenario.run(SERVER_URL); + + expect(checks.length).toBeGreaterThan(0); + + const failures = checks.filter((c) => c.status === 'FAILURE'); + if (failures.length > 0) { + const failureMessages = failures + .map((c) => `${c.name}: ${c.errorMessage || c.description}`) + .join('\n '); + throw new Error(`Scenario failed with checks:\n ${failureMessages}`); + } + }, 15000); + } +}); diff --git a/src/seps/sep-2322.yaml b/src/seps/sep-2322.yaml new file mode 100644 index 00000000..32a5aea0 --- /dev/null +++ b/src/seps/sep-2322.yaml @@ -0,0 +1,184 @@ +sep: 2322 +spec_url: https://modelcontextprotocol.io/specification/draft/basic/utilities/mrtr + +requirements: + # ── Note (non-normative) ──────────────────────────────────────────────────── + # "Servers MUST send server-to-client requests ... using the MRTR pattern." + # This is an architectural requirement tested indirectly through all scenarios. + + # ── Supported Requests ────────────────────────────────────────────────────── + - text: "Servers MUST NOT send InputRequiredResult responses on any other client requests." + excluded: "Requires exhaustive negative testing of unsupported methods; not practical in conformance framework" + + # ── Server Requirements (Basic Workflow) ──────────────────────────────────── + + # Requirement: inputRequests keys MUST be unique within the scope of the request. + - check: input-required-result-multiple-inputs-input-required + text: "inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request." + + # Requirement: inputRequests values MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest. + - check: input-required-result-elicitation-input-required + text: "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest" + + - check: input-required-result-sampling-input-required + text: "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling)" + + - check: input-required-result-list-roots-input-required + text: "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots)" + + # Requirement: servers MUST treat requestState as attacker-controlled input + - text: "servers MUST treat requestState as an attacker-controlled input" + excluded: "Security implementation detail; not observable at protocol level" + + # Requirement: servers MUST protect integrity (HMAC/AEAD) if requestState influences auth/logic + - text: "servers MUST protect its integrity (e.g. HMAC or AEAD)" + excluded: "Security implementation detail; not observable at protocol level" + + # Requirement: servers MUST reject state that fails verification + - text: "MUST reject state that fails verification" + excluded: "Security implementation detail; not observable at protocol level" + + # Requirement: SHOULD include principal, TTL, request identifier in requestState + - text: "servers SHOULD include the authenticated principal, a short expiry (TTL), and an identifier for the originating request inside the integrity-protected requestState payload" + excluded: "Security implementation detail; not observable at protocol level" + + # Requirement: single-use requestState MUST be enforced server-side + - text: "Servers for which a given requestState must be consumed at most once MUST enforce that invariant server-side" + excluded: "Security implementation detail; not observable at protocol level" + + # Requirement: MUST include at least one of inputRequests or requestState + - check: input-required-result-request-state-input-required + text: "Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response." + + # Requirement: MUST NOT send inputRequests client hasn't declared support for + - text: "Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities" + excluded: "Requires testing with restricted client capabilities; framework limitation" + issue: https://github.com/modelcontextprotocol/conformance/issues/new + + # Requirement: MUST NOT assume clients will fulfill inputRequests + - text: "Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request" + excluded: "Server-internal assumption; not observable at protocol level" + + # ── Client Requirements (Basic Workflow) ──────────────────────────────────── + + - text: "client MUST construct the requested inputs before retrying the original request" + excluded: "Client behavior; not testable via server conformance tests" + + - text: "client MUST echo back the exact value of requestState when retrying" + excluded: "Client behavior; not testable via server conformance tests" + + - text: "Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents" + excluded: "Client behavior; not testable via server conformance tests" + + - text: "If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry" + excluded: "Client behavior; not testable via server conformance tests" + + - text: "The JSON-RPC id MUST be different between the initial request and the retry" + excluded: "Client behavior; not testable via server conformance tests" + + - text: "inputRequests and requestState MUST NOT be used for any other request the client may be sending in parallel" + excluded: "Client behavior; not testable via server conformance tests" + + # ── Server Requirements (Tasks) ──────────────────────────────────────────── + + # Requirement: MUST include inputRequests in tasks/result when input_required + - check: input-required-result-task-tasks-result-input-required + text: "Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required." + + # Requirement: inputRequests keys MUST be unique within scope of Task + - check: input-required-result-task-input-response-returns-input-required + text: "inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task." + + # ── Client Requirements (Tasks) ──────────────────────────────────────────── + + - text: "When tasks/get shows status input_required, clients MUST call tasks/result" + excluded: "Client behavior; not testable via server conformance tests" + + - text: "Clients SHOULD construct the results and call tasks/input_response" + excluded: "Client behavior; not testable via server conformance tests" + + # ── Error Handling ────────────────────────────────────────────────────────── + + # Requirement: SHOULD validate InputResponses + - check: input-required-result-task-bad-input-rerequests + text: "Servers SHOULD validate that the data provided by the client is a valid InputResponses object" + + # Requirement: SHOULD return JSON-RPC error for protocol errors + - text: "Protocol errors SHOULD return a JSON-RPC error response with an appropriate error code and message" + excluded: "Requires fault injection (malformed JSON); framework limitation" + + # Requirement: SHOULD ignore unrecognized extra parameters + - text: "server SHOULD ignore any information it does not recognize or need" + excluded: "Requires server-side behavior verification; not easily testable" + + # Requirement: SHOULD re-request missing information + - check: input-required-result-missing-response-rerequests + text: "server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error" + + # ── Security Considerations ───────────────────────────────────────────────── + + - text: "Servers MUST validate request state as described in the server requirements" + excluded: "Security implementation detail; not observable at protocol level" + + # ── Ephemeral workflow: complete result after retry ───────────────────────── + + - check: input-required-result-elicitation-complete + text: "Server returns complete result after retry with inputResponses (elicitation)" + + - check: input-required-result-sampling-complete + text: "Server returns complete result after retry with inputResponses (sampling)" + + - check: input-required-result-list-roots-complete + text: "Server returns complete result after retry with inputResponses (list roots)" + + - check: input-required-result-request-state-complete + text: "Server returns complete result after retry with requestState echoed back" + + - check: input-required-result-multiple-inputs-complete + text: "Server returns complete result after retry with multiple inputResponses" + + - check: input-required-result-multi-round-r1 + text: "Server returns InputRequiredResult on first round of multi-round flow" + + - check: input-required-result-multi-round-r2 + text: "Server returns InputRequiredResult on second round (re-requesting with new requestState)" + + - check: input-required-result-multi-round-r3 + text: "Server returns complete result on third round of multi-round flow" + + # ── Non-tool request (prompts/get) ────────────────────────────────────────── + + - check: input-required-result-non-tool-input-required + text: "Server returns InputRequiredResult on prompts/get request" + + - check: input-required-result-non-tool-complete + text: "Server returns complete GetPromptResult after retry with inputResponses" + + # ── Task workflow checks ──────────────────────────────────────────────────── + + - check: input-required-result-task-created + text: "Server creates task with status working" + + - check: input-required-result-task-input-required + text: "Task transitions to input_required status" + + - check: input-required-result-task-input-response-sent + text: "Server accepts tasks/input_response" + + - check: input-required-result-task-ack-structure + text: "tasks/input_response acknowledgement has correct structure" + + - check: input-required-result-task-completed + text: "Task transitions to completed status after input provided" + + - check: input-required-result-task-final-result + text: "tasks/result returns final content after task completes" + + - check: input-required-result-task-bad-input-prereq + text: "Prerequisite: task reaches input_required for bad input test" + + - check: input-required-result-task-multi-input-prereq + text: "Prerequisite: task created for multi-input test" + + - check: input-required-result-task-multi-input-completed + text: "Task completes after second input_response" From 1ae8855f25a3949e002e586ee36e851369ff5d3a Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Thu, 14 May 2026 18:02:37 -0700 Subject: [PATCH 02/13] fix formatting --- .../servers/typescript/sep-2322-no-mrtr.ts | 19 ++- .../authorization-server-metadata.ts | 4 +- src/scenarios/index.ts | 1 - .../server/input-required-result-tasks.ts | 6 +- src/scenarios/server/input-required-result.ts | 26 +++- src/scenarios/server/sep-2322-mrtr.test.ts | 31 ++-- src/seps/sep-2322.yaml | 132 +++++++++--------- 7 files changed, 116 insertions(+), 103 deletions(-) diff --git a/examples/servers/typescript/sep-2322-no-mrtr.ts b/examples/servers/typescript/sep-2322-no-mrtr.ts index 28583d0e..5f9d9f92 100644 --- a/examples/servers/typescript/sep-2322-no-mrtr.ts +++ b/examples/servers/typescript/sep-2322-no-mrtr.ts @@ -32,10 +32,7 @@ class InMemoryEventStore implements EventStore { new Map(); private counter = 0; - async storeEvent( - streamId: StreamId, - message: string - ): Promise { + async storeEvent(streamId: StreamId, message: string): Promise { const id = String(++this.counter); this.events.set(id, { streamId: streamId as string, message }); return id as EventId; @@ -43,9 +40,7 @@ class InMemoryEventStore implements EventStore { async replayEventsAfter( lastEventId: EventId, - { - send - }: { send: (eventId: EventId, message: string) => void } + { send }: { send: (eventId: EventId, message: string) => void } ): Promise { const start = parseInt(lastEventId as string, 10) || 0; for (const [id, evt] of this.events) { @@ -53,9 +48,9 @@ class InMemoryEventStore implements EventStore { send(id as EventId, evt.message); } } - return (this.events.size > 0 - ? String(this.counter) - : (lastEventId as string)) as string; + return ( + this.events.size > 0 ? String(this.counter) : (lastEventId as string) + ) as string; } } @@ -151,5 +146,7 @@ app.all('/mcp', async (req, res) => { }); app.listen(PORT, () => { - console.log(`sep-2322-no-mrtr server running on http://localhost:${PORT}/mcp`); + console.log( + `sep-2322-no-mrtr server running on http://localhost:${PORT}/mcp` + ); }); diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index 00c930fc..50049603 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -9,7 +9,9 @@ import { request } from 'undici'; type Status = 'SUCCESS' | 'FAILURE'; -export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer { +export class AuthorizationServerMetadataEndpointScenario + implements ClientScenarioForAuthorizationServer +{ name = 'authorization-server-metadata-endpoint'; readonly source = { introducedIn: '2025-03-26' } as const; description = `Test authorization server metadata endpoint. diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 5cc4f872..71de87a0 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -116,7 +116,6 @@ const pendingClientScenariosList: ClientScenario[] = [ new InputRequiredResultTaskBasicScenario(), new InputRequiredResultTaskBadInputResponseScenario(), new InputRequiredResultTaskInputResponseInputRequiredScenario() - ]; // All client scenarios diff --git a/src/scenarios/server/input-required-result-tasks.ts b/src/scenarios/server/input-required-result-tasks.ts index d561fe17..5e979e56 100644 --- a/src/scenarios/server/input-required-result-tasks.ts +++ b/src/scenarios/server/input-required-result-tasks.ts @@ -183,7 +183,8 @@ Implement a tool named \`test_input_required_result_task\` that supports task-au checks.push({ id: 'input-required-result-task-tasks-result-incomplete', name: 'InputRequiredResultTaskTasksResultIncomplete', - description: 'tasks/result returns InputRequiredResult with inputRequests', + description: + 'tasks/result returns InputRequiredResult with inputRequests', status: r3Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r3Errors.length > 0 ? r3Errors.join('; ') : undefined, @@ -191,7 +192,8 @@ Implement a tool named \`test_input_required_result_task\` that supports task-au details: { result: r3Result } }); - if (r3Errors.length > 0 || !isInputRequiredResult(r3Result)) return checks; + if (r3Errors.length > 0 || !isInputRequiredResult(r3Result)) + return checks; // Step 4: Call tasks/input_response with inputResponses const inputKey = Object.keys(r3Result.inputRequests!)[0]; diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 2334031c..26373b94 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -176,7 +176,9 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments // ─── A2: Basic Sampling ────────────────────────────────────────────────────── -export class InputRequiredResultBasicSamplingScenario implements ClientScenario { +export class InputRequiredResultBasicSamplingScenario + implements ClientScenario +{ name = 'input-required-result-basic-sampling'; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). @@ -227,7 +229,9 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req } else if (!r1Result) { r1Errors.push('No result in response'); } else if (!isInputRequiredResult(r1Result)) { - r1Errors.push('Expected InputRequiredResult with sampling inputRequest'); + r1Errors.push( + 'Expected InputRequiredResult with sampling inputRequest' + ); } else { if (!r1Result.inputRequests) { r1Errors.push('InputRequiredResult missing inputRequests'); @@ -316,7 +320,9 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req // ─── A3: Basic ListRoots ───────────────────────────────────────────────────── -export class InputRequiredResultBasicListRootsScenario implements ClientScenario { +export class InputRequiredResultBasicListRootsScenario + implements ClientScenario +{ name = 'input-required-result-basic-list-roots'; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). @@ -361,7 +367,9 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r } else if (!r1Result) { r1Errors.push('No result in response'); } else if (!isInputRequiredResult(r1Result)) { - r1Errors.push('Expected InputRequiredResult with roots/list inputRequest'); + r1Errors.push( + 'Expected InputRequiredResult with roots/list inputRequest' + ); } else { if (!r1Result.inputRequests) { r1Errors.push('InputRequiredResult missing inputRequests'); @@ -1031,7 +1039,9 @@ Use the same tool as A1: \`test_input_required_result_elicitation\`. // ─── A9: Non-Tool Request (prompts/get) ────────────────────────────────────── -export class InputRequiredResultNonToolRequestScenario implements ClientScenario { +export class InputRequiredResultNonToolRequestScenario + implements ClientScenario +{ name = 'input-required-result-non-tool-request'; specVersions: SpecVersion[] = ['draft']; description = `Test InputRequiredResult on a non-tool request (prompts/get) to verify InputRequiredResult is universal (SEP-2322). @@ -1088,7 +1098,8 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli checks.push({ id: 'input-required-result-non-tool-incomplete', name: 'InputRequiredResultNonToolIncomplete', - description: 'prompts/get returns InputRequiredResult with inputRequests', + description: + 'prompts/get returns InputRequiredResult with inputRequests', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, @@ -1140,7 +1151,8 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli checks.push({ id: 'input-required-result-non-tool-incomplete', name: 'InputRequiredResultNonToolIncomplete', - description: 'prompts/get returns InputRequiredResult with inputRequests', + description: + 'prompts/get returns InputRequiredResult with inputRequests', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/scenarios/server/sep-2322-mrtr.test.ts b/src/scenarios/server/sep-2322-mrtr.test.ts index 66f83640..947a029c 100644 --- a/src/scenarios/server/sep-2322-mrtr.test.ts +++ b/src/scenarios/server/sep-2322-mrtr.test.ts @@ -24,10 +24,7 @@ import { InputRequiredResultTaskInputResponseInputRequiredScenario } from './input-required-result-tasks'; -function startServer( - scriptPath: string, - port: number -): Promise { +function startServer(scriptPath: string, port: number): Promise { return new Promise((resolve, reject) => { const isWindows = process.platform === 'win32'; const proc = spawn('npx', ['tsx', scriptPath], { @@ -105,18 +102,22 @@ describe('SEP-2322 MRTR positive tests', () => { ]; for (const scenario of scenarios) { - it(scenario.name, async () => { - const checks = await scenario.run(SERVER_URL); + it( + scenario.name, + async () => { + const checks = await scenario.run(SERVER_URL); - expect(checks.length).toBeGreaterThan(0); + expect(checks.length).toBeGreaterThan(0); - const failures = checks.filter((c) => c.status === 'FAILURE'); - if (failures.length > 0) { - const failureMessages = failures - .map((c) => `${c.name}: ${c.errorMessage || c.description}`) - .join('\n '); - throw new Error(`Scenario failed with checks:\n ${failureMessages}`); - } - }, 15000); + const failures = checks.filter((c) => c.status === 'FAILURE'); + if (failures.length > 0) { + const failureMessages = failures + .map((c) => `${c.name}: ${c.errorMessage || c.description}`) + .join('\n '); + throw new Error(`Scenario failed with checks:\n ${failureMessages}`); + } + }, + 15000 + ); } }); diff --git a/src/seps/sep-2322.yaml b/src/seps/sep-2322.yaml index 32a5aea0..2f21e7cb 100644 --- a/src/seps/sep-2322.yaml +++ b/src/seps/sep-2322.yaml @@ -7,178 +7,178 @@ requirements: # This is an architectural requirement tested indirectly through all scenarios. # ── Supported Requests ────────────────────────────────────────────────────── - - text: "Servers MUST NOT send InputRequiredResult responses on any other client requests." - excluded: "Requires exhaustive negative testing of unsupported methods; not practical in conformance framework" + - text: 'Servers MUST NOT send InputRequiredResult responses on any other client requests.' + excluded: 'Requires exhaustive negative testing of unsupported methods; not practical in conformance framework' # ── Server Requirements (Basic Workflow) ──────────────────────────────────── # Requirement: inputRequests keys MUST be unique within the scope of the request. - check: input-required-result-multiple-inputs-input-required - text: "inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request." + text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of the request.' # Requirement: inputRequests values MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest. - check: input-required-result-elicitation-input-required - text: "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest" + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest' - check: input-required-result-sampling-input-required - text: "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling)" + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (sampling)' - check: input-required-result-list-roots-input-required - text: "inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots)" + text: 'inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest (list roots)' # Requirement: servers MUST treat requestState as attacker-controlled input - - text: "servers MUST treat requestState as an attacker-controlled input" - excluded: "Security implementation detail; not observable at protocol level" + - text: 'servers MUST treat requestState as an attacker-controlled input' + excluded: 'Security implementation detail; not observable at protocol level' # Requirement: servers MUST protect integrity (HMAC/AEAD) if requestState influences auth/logic - - text: "servers MUST protect its integrity (e.g. HMAC or AEAD)" - excluded: "Security implementation detail; not observable at protocol level" + - text: 'servers MUST protect its integrity (e.g. HMAC or AEAD)' + excluded: 'Security implementation detail; not observable at protocol level' # Requirement: servers MUST reject state that fails verification - - text: "MUST reject state that fails verification" - excluded: "Security implementation detail; not observable at protocol level" + - text: 'MUST reject state that fails verification' + excluded: 'Security implementation detail; not observable at protocol level' # Requirement: SHOULD include principal, TTL, request identifier in requestState - - text: "servers SHOULD include the authenticated principal, a short expiry (TTL), and an identifier for the originating request inside the integrity-protected requestState payload" - excluded: "Security implementation detail; 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' + excluded: 'Security implementation detail; not observable at protocol level' # Requirement: single-use requestState MUST be enforced server-side - - text: "Servers for which a given requestState must be consumed at most once MUST enforce that invariant server-side" - excluded: "Security implementation detail; 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: 'Security implementation detail; not observable at protocol level' # Requirement: MUST include at least one of inputRequests or requestState - check: input-required-result-request-state-input-required - text: "Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response." + text: 'Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response.' # Requirement: MUST NOT send inputRequests client hasn't declared support for - - text: "Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities" - excluded: "Requires testing with restricted client capabilities; framework limitation" + - text: 'Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities' + excluded: 'Requires testing with restricted client capabilities; framework limitation' issue: https://github.com/modelcontextprotocol/conformance/issues/new # Requirement: MUST NOT assume clients will fulfill inputRequests - - text: "Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request" - excluded: "Server-internal assumption; not observable at protocol level" + - text: 'Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request' + excluded: 'Server-internal assumption; not observable at protocol level' # ── Client Requirements (Basic Workflow) ──────────────────────────────────── - - text: "client MUST construct the requested inputs before retrying the original request" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'client MUST construct the requested inputs before retrying the original request' + excluded: 'Client behavior; not testable via server conformance tests' - - text: "client MUST echo back the exact value of requestState when retrying" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'client MUST echo back the exact value of requestState when retrying' + excluded: 'Client behavior; not testable via server conformance tests' - - text: "Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents' + excluded: 'Client behavior; not testable via server conformance tests' - - text: "If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry' + excluded: 'Client behavior; not testable via server conformance tests' - - text: "The JSON-RPC id MUST be different between the initial request and the retry" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'The JSON-RPC id MUST be different between the initial request and the retry' + excluded: 'Client behavior; not testable via server conformance tests' - - text: "inputRequests and requestState MUST NOT be used for any other request the client may be sending in parallel" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'inputRequests and requestState MUST NOT be used for any other request the client may be sending in parallel' + excluded: 'Client behavior; not testable via server conformance tests' # ── Server Requirements (Tasks) ──────────────────────────────────────────── # Requirement: MUST include inputRequests in tasks/result when input_required - check: input-required-result-task-tasks-result-input-required - text: "Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required." + text: 'Servers MUST include an inputRequests field in the tasks/result response when the task is in status input_required.' # Requirement: inputRequests keys MUST be unique within scope of Task - check: input-required-result-task-input-response-returns-input-required - text: "inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task." + text: 'inputRequests keys are server assigned identifiers and MUST be unique within the scope of a Task.' # ── Client Requirements (Tasks) ──────────────────────────────────────────── - - text: "When tasks/get shows status input_required, clients MUST call tasks/result" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'When tasks/get shows status input_required, clients MUST call tasks/result' + excluded: 'Client behavior; not testable via server conformance tests' - - text: "Clients SHOULD construct the results and call tasks/input_response" - excluded: "Client behavior; not testable via server conformance tests" + - text: 'Clients SHOULD construct the results and call tasks/input_response' + excluded: 'Client behavior; not testable via server conformance tests' # ── Error Handling ────────────────────────────────────────────────────────── # Requirement: SHOULD validate InputResponses - check: input-required-result-task-bad-input-rerequests - text: "Servers SHOULD validate that the data provided by the client is a valid InputResponses object" + text: 'Servers SHOULD validate that the data provided by the client is a valid InputResponses object' # Requirement: SHOULD return JSON-RPC error for protocol errors - - text: "Protocol errors SHOULD return a JSON-RPC error response with an appropriate error code and message" - excluded: "Requires fault injection (malformed JSON); framework limitation" + - text: 'Protocol errors SHOULD return a JSON-RPC error response with an appropriate error code and message' + excluded: 'Requires fault injection (malformed JSON); framework limitation' # Requirement: SHOULD ignore unrecognized extra parameters - - text: "server SHOULD ignore any information it does not recognize or need" - excluded: "Requires server-side behavior verification; not easily testable" + - text: 'server SHOULD ignore any information it does not recognize or need' + excluded: 'Requires server-side behavior verification; not easily testable' # Requirement: SHOULD re-request missing information - check: input-required-result-missing-response-rerequests - text: "server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error" + text: 'server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error' # ── Security Considerations ───────────────────────────────────────────────── - - text: "Servers MUST validate request state as described in the server requirements" - excluded: "Security implementation detail; not observable at protocol level" + - text: 'Servers MUST validate request state as described in the server requirements' + excluded: 'Security implementation detail; not observable at protocol level' # ── Ephemeral workflow: complete result after retry ───────────────────────── - check: input-required-result-elicitation-complete - text: "Server returns complete result after retry with inputResponses (elicitation)" + text: 'Server returns complete result after retry with inputResponses (elicitation)' - check: input-required-result-sampling-complete - text: "Server returns complete result after retry with inputResponses (sampling)" + text: 'Server returns complete result after retry with inputResponses (sampling)' - check: input-required-result-list-roots-complete - text: "Server returns complete result after retry with inputResponses (list roots)" + text: 'Server returns complete result after retry with inputResponses (list roots)' - check: input-required-result-request-state-complete - text: "Server returns complete result after retry with requestState echoed back" + text: 'Server returns complete result after retry with requestState echoed back' - check: input-required-result-multiple-inputs-complete - text: "Server returns complete result after retry with multiple inputResponses" + text: 'Server returns complete result after retry with multiple inputResponses' - check: input-required-result-multi-round-r1 - text: "Server returns InputRequiredResult on first round of multi-round flow" + text: 'Server returns InputRequiredResult on first round of multi-round flow' - check: input-required-result-multi-round-r2 - text: "Server returns InputRequiredResult on second round (re-requesting with new requestState)" + text: 'Server returns InputRequiredResult on second round (re-requesting with new requestState)' - check: input-required-result-multi-round-r3 - text: "Server returns complete result on third round of multi-round flow" + text: 'Server returns complete result on third round of multi-round flow' # ── Non-tool request (prompts/get) ────────────────────────────────────────── - check: input-required-result-non-tool-input-required - text: "Server returns InputRequiredResult on prompts/get request" + text: 'Server returns InputRequiredResult on prompts/get request' - check: input-required-result-non-tool-complete - text: "Server returns complete GetPromptResult after retry with inputResponses" + text: 'Server returns complete GetPromptResult after retry with inputResponses' # ── Task workflow checks ──────────────────────────────────────────────────── - check: input-required-result-task-created - text: "Server creates task with status working" + text: 'Server creates task with status working' - check: input-required-result-task-input-required - text: "Task transitions to input_required status" + text: 'Task transitions to input_required status' - check: input-required-result-task-input-response-sent - text: "Server accepts tasks/input_response" + text: 'Server accepts tasks/input_response' - check: input-required-result-task-ack-structure - text: "tasks/input_response acknowledgement has correct structure" + text: 'tasks/input_response acknowledgement has correct structure' - check: input-required-result-task-completed - text: "Task transitions to completed status after input provided" + text: 'Task transitions to completed status after input provided' - check: input-required-result-task-final-result - text: "tasks/result returns final content after task completes" + text: 'tasks/result returns final content after task completes' - check: input-required-result-task-bad-input-prereq - text: "Prerequisite: task reaches input_required for bad input test" + text: 'Prerequisite: task reaches input_required for bad input test' - check: input-required-result-task-multi-input-prereq - text: "Prerequisite: task created for multi-input test" + text: 'Prerequisite: task created for multi-input test' - check: input-required-result-task-multi-input-completed - text: "Task completes after second input_response" + text: 'Task completes after second input_response' From 7324cbaa43a85ed5a14551f72be21a5bfaccc887 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 08:41:20 -0700 Subject: [PATCH 03/13] remove session id and cleanup resultType Checks --- src/scenarios/server/client-helper.ts | 4 ---- src/scenarios/server/input-required-result-helpers.ts | 6 ++---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/scenarios/server/client-helper.ts b/src/scenarios/server/client-helper.ts index 95d8e949..1447eea0 100644 --- a/src/scenarios/server/client-helper.ts +++ b/src/scenarios/server/client-helper.ts @@ -114,10 +114,6 @@ export class RawMcpSession { Accept: 'application/json, text/event-stream' }; - if (this.sessionId) { - headers['Mcp-Session-Id'] = this.sessionId; - } - const body = JSON.stringify({ jsonrpc: '2.0', id, diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index b78e1616..67dbac63 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -35,8 +35,7 @@ export function isInputRequiredResult( ): result is InputRequiredResultData { if (!result) return false; if (result.resultType === 'input_required') return true; - // Also detect by presence of InputRequiredResult fields - return 'inputRequests' in result || 'requestState' in result; + return false; } /** @@ -48,8 +47,7 @@ export function isCompleteResult( ): boolean { if (!result) return false; if (result.resultType === 'complete') return true; - if (!('resultType' in result)) return true; - return !isInputRequiredResult(result); + return false; } /** From e203295020b6f475f3e0c23169ead8307e86f4b2 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 11:03:59 -0700 Subject: [PATCH 04/13] remove tasks --- .../authorization-server-metadata.ts | 4 +- src/scenarios/index.ts | 18 +- .../server/input-required-result-tasks.ts | 641 ------------------ src/scenarios/server/input-required-result.ts | 24 +- .../{sep-2322-mrtr.test.ts => mrtr.test.ts} | 10 +- 5 files changed, 11 insertions(+), 686 deletions(-) delete mode 100644 src/scenarios/server/input-required-result-tasks.ts rename src/scenarios/server/{sep-2322-mrtr.test.ts => mrtr.test.ts} (88%) diff --git a/src/scenarios/authorization-server/authorization-server-metadata.ts b/src/scenarios/authorization-server/authorization-server-metadata.ts index 50049603..00c930fc 100644 --- a/src/scenarios/authorization-server/authorization-server-metadata.ts +++ b/src/scenarios/authorization-server/authorization-server-metadata.ts @@ -9,9 +9,7 @@ import { request } from 'undici'; type Status = 'SUCCESS' | 'FAILURE'; -export class AuthorizationServerMetadataEndpointScenario - implements ClientScenarioForAuthorizationServer -{ +export class AuthorizationServerMetadataEndpointScenario implements ClientScenarioForAuthorizationServer { name = 'authorization-server-metadata-endpoint'; readonly source = { introducedIn: '2025-03-26' } as const; description = `Test authorization server metadata endpoint. diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index 71de87a0..b694b552 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -76,11 +76,7 @@ import { InputRequiredResultNonToolRequestScenario } from './server/input-required-result'; -import { - InputRequiredResultTaskBasicScenario, - InputRequiredResultTaskBadInputResponseScenario, - InputRequiredResultTaskInputResponseInputRequiredScenario -} from './server/input-required-result-tasks'; + import { authScenariosList, @@ -112,10 +108,7 @@ const pendingClientScenariosList: ClientScenario[] = [ new InputRequiredResultMultipleInputRequestsScenario(), new InputRequiredResultMultiRoundScenario(), new InputRequiredResultMissingInputResponseScenario(), - new InputRequiredResultNonToolRequestScenario(), - new InputRequiredResultTaskBasicScenario(), - new InputRequiredResultTaskBadInputResponseScenario(), - new InputRequiredResultTaskInputResponseInputRequiredScenario() + new InputRequiredResultNonToolRequestScenario() ]; // All client scenarios @@ -183,12 +176,7 @@ const allClientScenariosList: ClientScenario[] = [ new InputRequiredResultMultipleInputRequestsScenario(), new InputRequiredResultMultiRoundScenario(), new InputRequiredResultMissingInputResponseScenario(), - new InputRequiredResultNonToolRequestScenario(), - - // InputRequiredResult Task scenarios (SEP-2322) - new InputRequiredResultTaskBasicScenario(), - new InputRequiredResultTaskBadInputResponseScenario(), - new InputRequiredResultTaskInputResponseInputRequiredScenario() + new InputRequiredResultNonToolRequestScenario() ]; // Active client scenarios (excludes pending) diff --git a/src/scenarios/server/input-required-result-tasks.ts b/src/scenarios/server/input-required-result-tasks.ts deleted file mode 100644 index 5e979e56..00000000 --- a/src/scenarios/server/input-required-result-tasks.ts +++ /dev/null @@ -1,641 +0,0 @@ -/** - * SEP-2322: MRTR Tests for Persistent Workflow aka Tasks - * - * Tests the persistent (task-based) workflow where servers use Tasks to - * manage long-running operations that require additional input via - * tasks/get → input_required → tasks/result → tasks/input_response. - */ - -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; -import { createRawSession } from './client-helper'; -import { - isInputRequiredResult, - isCompleteResult, - mockElicitResponse, - MRTR_SPEC_REFERENCES, - RawMcpSession -} from './input-required-result-helpers'; - -/** - * Poll tasks/get until the task reaches the expected status or times out. - */ -async function pollTaskStatus( - session: RawMcpSession, - taskId: string, - expectedStatus: string, - maxAttempts: number = 20, - intervalMs: number = 250 -): Promise | null> { - for (let i = 0; i < maxAttempts; i++) { - const response = await session.send('tasks/get', { taskId }); - if (response.error) return null; - const result = response.result; - if (!result) return null; - if (result.status === expectedStatus) return result; - // If already completed/failed, stop polling - if ( - result.status === 'completed' || - result.status === 'failed' || - result.status === 'cancelled' - ) { - return result; - } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - return null; -} - -// ─── B1: Basic Persistent Workflow ─────────────────────────────────────────── - -export class InputRequiredResultTaskBasicScenario implements ClientScenario { - name = 'input-required-result-task-basic'; - specVersions: SpecVersion[] = ['draft']; - description = `Test full persistent InputRequiredResult workflow via Tasks API (SEP-2322). - -**Server Implementation Requirements:** - -Implement a tool named \`test_input_required_result_task\` that supports task-augmented execution. - -**Behavior:** -1. When called with \`task\` metadata, return a \`CreateTaskResult\` with \`status: "working"\` -2. After a brief period, set task status to \`"input_required"\` -3. When \`tasks/result\` is called, return an \`InputRequiredResult\` with \`inputRequests\`: - -\`\`\`json -{ - "resultType": "input_required", - "inputRequests": { - "user_input": { - "method": "elicitation/create", - "params": { - "message": "What input should the task use?", - "requestedSchema": { - "type": "object", - "properties": { "input": { "type": "string" } }, - "required": ["input"] - } - } - } - } -} -\`\`\` - -4. When \`tasks/input_response\` is called with \`inputResponses\`, acknowledge and resume -5. Set task status to \`"completed"\` -6. When \`tasks/result\` is called again, return the final result with tool content`; - - async run(serverUrl: string): Promise { - const checks: ConformanceCheck[] = []; - - try { - const session = await createRawSession(serverUrl); - - // Step 1: Call tool with task metadata - const r1 = await session.send('tools/call', { - name: 'test_input_required_result_task', - arguments: {}, - task: { ttl: 30000 } - }); - - const r1Result = r1.result; - const r1Errors: string[] = []; - let taskId: string | undefined; - - if (r1.error) { - r1Errors.push(`JSON-RPC error: ${r1.error.message}`); - } else if (!r1Result) { - r1Errors.push('No result in response'); - } else { - const task = r1Result.task as - | { taskId?: string; status?: string } - | undefined; - if (!task?.taskId) { - r1Errors.push('Expected CreateTaskResult with task.taskId'); - } else { - taskId = task.taskId; - if (task.status !== 'working') { - r1Errors.push( - `Expected initial task status "working", got "${task.status}"` - ); - } - } - } - - checks.push({ - id: 'input-required-result-task-created', - name: 'InputRequiredResultTaskCreated', - description: 'Server creates task with working status', - status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r1Result, taskId } - }); - - if (!taskId) return checks; - - // Step 2: Poll tasks/get until input_required - const taskState = await pollTaskStatus(session, taskId, 'input_required'); - - const pollErrors: string[] = []; - if (!taskState) { - pollErrors.push( - 'Task did not reach input_required status within timeout' - ); - } else if (taskState.status !== 'input_required') { - pollErrors.push( - `Expected status "input_required", got "${taskState.status}"` - ); - } - - checks.push({ - id: 'input-required-result-task-input-required', - name: 'InputRequiredResultTaskInputRequired', - description: 'Task reaches input_required status', - status: pollErrors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: pollErrors.length > 0 ? pollErrors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { taskState } - }); - - if (pollErrors.length > 0) return checks; - - // Step 3: Call tasks/result to get inputRequests - const r3 = await session.send('tasks/result', { taskId }); - const r3Result = r3.result; - const r3Errors: string[] = []; - - if (r3.error) { - r3Errors.push(`JSON-RPC error: ${r3.error.message}`); - } else if (!r3Result) { - r3Errors.push('No result from tasks/result'); - } else if (!isInputRequiredResult(r3Result)) { - r3Errors.push( - 'Expected InputRequiredResult with inputRequests from tasks/result' - ); - } else if (!r3Result.inputRequests) { - r3Errors.push( - 'InputRequiredResult from tasks/result missing inputRequests' - ); - } - - checks.push({ - id: 'input-required-result-task-tasks-result-incomplete', - name: 'InputRequiredResultTaskTasksResultIncomplete', - description: - 'tasks/result returns InputRequiredResult with inputRequests', - status: r3Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r3Errors.length > 0 ? r3Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r3Result } - }); - - if (r3Errors.length > 0 || !isInputRequiredResult(r3Result)) - return checks; - - // Step 4: Call tasks/input_response with inputResponses - const inputKey = Object.keys(r3Result.inputRequests!)[0]; - const r4 = await session.send('tasks/input_response', { - inputResponses: { - [inputKey]: mockElicitResponse({ input: 'Hello World!' }) - }, - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - }); - - const r4Errors: string[] = []; - if (r4.error) { - r4Errors.push(`JSON-RPC error: ${r4.error.message}`); - } - - checks.push({ - id: 'input-required-result-task-input-response-sent', - name: 'InputRequiredResultTaskInputResponseSent', - description: 'tasks/input_response is acknowledged by server', - status: r4Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r4Errors.length > 0 ? r4Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r4.result } - }); - - // Validate acknowledgment includes task metadata (SHOULD per spec) - if (r4Errors.length === 0) { - const r4Result = r4.result; - const ackErrors: string[] = []; - - if (!r4Result) { - ackErrors.push('No result from tasks/input_response'); - } else { - const meta = r4Result._meta as Record | undefined; - const relatedTask = meta?.['io.modelcontextprotocol/related-task'] as - | { taskId?: string } - | undefined; - if (!relatedTask?.taskId) { - ackErrors.push( - 'Acknowledgment missing _meta.io.modelcontextprotocol/related-task.taskId' - ); - } else if (relatedTask.taskId !== taskId) { - ackErrors.push( - `taskId mismatch: expected "${taskId}", got "${relatedTask.taskId}"` - ); - } - } - - checks.push({ - id: 'input-required-result-task-ack-structure', - name: 'InputRequiredResultTaskAckStructure', - description: - 'tasks/input_response acknowledgment includes task metadata', - status: ackErrors.length === 0 ? 'SUCCESS' : 'WARNING', - timestamp: new Date().toISOString(), - errorMessage: ackErrors.length > 0 ? ackErrors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r4Result } - }); - } - - if (r4Errors.length > 0) return checks; - - // Step 5: Poll until completed - const completedState = await pollTaskStatus(session, taskId, 'completed'); - - const compErrors: string[] = []; - if (!completedState) { - compErrors.push('Task did not reach completed status within timeout'); - } else if (completedState.status !== 'completed') { - compErrors.push( - `Expected status "completed", got "${completedState.status}"` - ); - } - - checks.push({ - id: 'input-required-result-task-completed', - name: 'InputRequiredResultTaskCompleted', - description: 'Task reaches completed status after input_response', - status: compErrors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: compErrors.length > 0 ? compErrors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { taskState: completedState } - }); - - if (compErrors.length > 0) return checks; - - // Step 6: Get final result - const r6 = await session.send('tasks/result', { taskId }); - const r6Result = r6.result; - const r6Errors: string[] = []; - - if (r6.error) { - r6Errors.push(`JSON-RPC error: ${r6.error.message}`); - } else if (!r6Result) { - r6Errors.push('No result from final tasks/result'); - } else if (!isCompleteResult(r6Result)) { - r6Errors.push('Expected complete result from final tasks/result'); - } else if (!r6Result.content) { - r6Errors.push('Final result missing content'); - } - - checks.push({ - id: 'input-required-result-task-final-result', - name: 'InputRequiredResultTaskFinalResult', - description: 'tasks/result returns complete final result', - status: r6Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r6Errors.length > 0 ? r6Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r6Result } - }); - } catch (error) { - checks.push({ - id: 'input-required-result-task-created', - name: 'InputRequiredResultTaskCreated', - description: 'Server creates task with working status', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: MRTR_SPEC_REFERENCES - }); - } - - return checks; - } -} - -// ─── B2: Bad Input Response ────────────────────────────────────────────────── - -export class InputRequiredResultTaskBadInputResponseScenario - implements ClientScenario -{ - name = 'input-required-result-task-bad-input-response'; - specVersions: SpecVersion[] = ['draft']; - description = `Test error handling when tasks/input_response contains wrong data (SEP-2322). - -**Server Implementation Requirements:** - -Use the same tool as B1: \`test_input_required_result_task\`. - -**Behavior:** When the client sends \`tasks/input_response\` with incorrect keys, the server SHOULD acknowledge the message but keep the task in \`input_required\` status. The next \`tasks/result\` call should return a new \`inputRequests\` re-requesting the needed information.`; - - async run(serverUrl: string): Promise { - const checks: ConformanceCheck[] = []; - - try { - const session = await createRawSession(serverUrl); - - // Create task and wait for input_required - const r1 = await session.send('tools/call', { - name: 'test_input_required_result_task', - arguments: {}, - task: { ttl: 30000 } - }); - - const task = r1.result?.task as { taskId?: string } | undefined; - if (!task?.taskId) { - checks.push({ - id: 'input-required-result-task-bad-input-prereq', - name: 'InputRequiredResultTaskBadInputPrereq', - description: 'Prerequisite: Task creation', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: 'Could not create task', - specReferences: MRTR_SPEC_REFERENCES - }); - return checks; - } - - const taskId = task.taskId; - await pollTaskStatus(session, taskId, 'input_required'); - - // Get input requests - const r3 = await session.send('tasks/result', { taskId }); - if ( - r3.error || - !r3.result || - !isInputRequiredResult(r3.result) || - !r3.result.inputRequests - ) { - checks.push({ - id: 'input-required-result-task-bad-input-prereq', - name: 'InputRequiredResultTaskBadInputPrereq', - description: 'Prerequisite: Get inputRequests', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: 'Could not get inputRequests', - specReferences: MRTR_SPEC_REFERENCES - }); - return checks; - } - - // Send wrong inputResponses - const r4 = await session.send('tasks/input_response', { - inputResponses: { - wrong_key: mockElicitResponse({ wrong: 'data' }) - }, - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - }); - - const ackErrors: string[] = []; - if (r4.error) { - // Some servers may error; that's acceptable - ackErrors.push( - 'Server returned error for bad input (acceptable but not preferred)' - ); - } - - // Check task is still input_required - const stateAfter = await session.send('tasks/get', { taskId }); - const stillInputRequired = stateAfter.result?.status === 'input_required'; - - // Try to get new inputRequests - let newInputRequests = false; - if (stillInputRequired) { - const r5 = await session.send('tasks/result', { taskId }); - if ( - r5.result && - isInputRequiredResult(r5.result) && - r5.result.inputRequests - ) { - newInputRequests = true; - } - } - - const errors: string[] = []; - if (!stillInputRequired && ackErrors.length === 0) { - errors.push( - 'Task should remain in input_required after bad inputResponses' - ); - } - if (stillInputRequired && !newInputRequests) { - errors.push( - 'tasks/result should return new inputRequests after bad input_response' - ); - } - - checks.push({ - id: 'input-required-result-task-bad-input-rerequests', - name: 'InputRequiredResultTaskBadInputRerequests', - description: - 'Server keeps task in input_required and re-requests after bad inputResponses', - status: - errors.length === 0 - ? ackErrors.length === 0 - ? 'SUCCESS' - : 'WARNING' - : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: - [...errors, ...ackErrors].length > 0 - ? [...errors, ...ackErrors].join('; ') - : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { - stillInputRequired, - newInputRequests, - ackResult: r4.result - } - }); - } catch (error) { - checks.push({ - id: 'input-required-result-task-bad-input-rerequests', - name: 'InputRequiredResultTaskBadInputRerequests', - description: - 'Server keeps task in input_required and re-requests after bad inputResponses', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, - specReferences: MRTR_SPEC_REFERENCES - }); - } - - return checks; - } -} - -// ─── B4: tasks/input_response returning InputRequiredResult ───────────────────── - -export class InputRequiredResultTaskInputResponseInputRequiredScenario - implements ClientScenario -{ - name = 'input-required-result-task-input-response-incomplete'; - specVersions: SpecVersion[] = ['draft']; - description = `Test that tasks/input_response can itself return an InputRequiredResult (SEP-2322). - -**Server Implementation Requirements:** - -Implement a tool named \`test_input_required_result_task_multi_input\` that supports task-augmented execution and requires TWO rounds of input. - -**Behavior:** -1. Create task, transition to \`input_required\` -2. \`tasks/result\` returns InputRequiredResult with first \`inputRequests\` -3. \`tasks/input_response\` returns an \`InputRequiredResult\` with ADDITIONAL \`inputRequests\` -4. Client sends another \`tasks/input_response\` with the additional responses -5. Task completes - -This tests the schema: \`TaskInputResponseResultResponse.result: Result | InputRequiredResult\``; - - async run(serverUrl: string): Promise { - const checks: ConformanceCheck[] = []; - - try { - const session = await createRawSession(serverUrl); - - // Create task - const r1 = await session.send('tools/call', { - name: 'test_input_required_result_task_multi_input', - arguments: {}, - task: { ttl: 30000 } - }); - - const task = r1.result?.task as { taskId?: string } | undefined; - if (!task?.taskId) { - checks.push({ - id: 'input-required-result-task-multi-input-prereq', - name: 'InputRequiredResultTaskMultiInputPrereq', - description: 'Prerequisite: Task creation', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: 'Could not create task', - specReferences: MRTR_SPEC_REFERENCES - }); - return checks; - } - - const taskId = task.taskId; - await pollTaskStatus(session, taskId, 'input_required'); - - // Get first inputRequests - const r3 = await session.send('tasks/result', { taskId }); - if ( - r3.error || - !r3.result || - !isInputRequiredResult(r3.result) || - !r3.result.inputRequests - ) { - checks.push({ - id: 'input-required-result-task-multi-input-prereq', - name: 'InputRequiredResultTaskMultiInputPrereq', - description: 'Prerequisite: Get first inputRequests', - status: 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: 'Could not get inputRequests from tasks/result', - specReferences: MRTR_SPEC_REFERENCES - }); - return checks; - } - - // Send first input_response — expect InputRequiredResult back - const inputKey1 = Object.keys(r3.result.inputRequests!)[0]; - const r4 = await session.send('tasks/input_response', { - inputResponses: { - [inputKey1]: mockElicitResponse({ input: 'step1' }) - }, - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - }); - - const r4Result = r4.result; - const r4Errors: string[] = []; - - if (r4.error) { - r4Errors.push(`JSON-RPC error: ${r4.error.message}`); - } else if (!r4Result) { - r4Errors.push('No result from tasks/input_response'); - } else if (!isInputRequiredResult(r4Result)) { - r4Errors.push( - 'Expected InputRequiredResult from tasks/input_response (additional input needed)' - ); - } else if (!r4Result.inputRequests) { - r4Errors.push( - 'InputRequiredResult from tasks/input_response missing inputRequests' - ); - } - - checks.push({ - id: 'input-required-result-task-input-response-returns-incomplete', - name: 'InputRequiredResultTaskInputResponseReturnsIncomplete', - description: - 'tasks/input_response returns InputRequiredResult with additional inputRequests', - status: r4Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r4Errors.length > 0 ? r4Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r4Result } - }); - - // Send second input_response — expect completion - if (r4Errors.length === 0 && isInputRequiredResult(r4Result)) { - const inputKey2 = Object.keys(r4Result.inputRequests!)[0]; - const r5 = await session.send('tasks/input_response', { - inputResponses: { - [inputKey2]: mockElicitResponse({ input: 'step2' }) - }, - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } - } - }); - - const r5Ok = !r5.error; - - // Poll for completion - if (r5Ok) { - const finalState = await pollTaskStatus(session, taskId, 'completed'); - - checks.push({ - id: 'input-required-result-task-multi-input-completed', - name: 'InputRequiredResultTaskMultiInputCompleted', - description: 'Task completes after second input_response', - status: finalState?.status === 'completed' ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: - finalState?.status !== 'completed' - ? 'Task did not complete after second input_response' - : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { taskState: finalState } - }); - } - } - } catch (error) { - checks.push({ - id: 'input-required-result-task-input-response-returns-incomplete', - name: 'InputRequiredResultTaskInputResponseReturnsIncomplete', - description: - 'tasks/input_response returns InputRequiredResult with additional inputRequests', - status: 'FAILURE', - 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/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 26373b94..2afe9457 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -19,9 +19,7 @@ import { // ─── A1: Basic Elicitation ──────────────────────────────────────────────────── -export class InputRequiredResultBasicElicitationScenario - implements ClientScenario -{ +export class InputRequiredResultBasicElicitationScenario implements ClientScenario { name = 'input-required-result-basic-elicitation'; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). @@ -176,9 +174,7 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments // ─── A2: Basic Sampling ────────────────────────────────────────────────────── -export class InputRequiredResultBasicSamplingScenario - implements ClientScenario -{ +export class InputRequiredResultBasicSamplingScenario implements ClientScenario { name = 'input-required-result-basic-sampling'; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). @@ -320,9 +316,7 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req // ─── A3: Basic ListRoots ───────────────────────────────────────────────────── -export class InputRequiredResultBasicListRootsScenario - implements ClientScenario -{ +export class InputRequiredResultBasicListRootsScenario implements ClientScenario { name = 'input-required-result-basic-list-roots'; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). @@ -601,9 +595,7 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument // ─── A5: Multiple Input Requests ───────────────────────────────────────────── -export class InputRequiredResultMultipleInputRequestsScenario - implements ClientScenario -{ +export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { name = 'input-required-result-multiple-input-requests'; specVersions: SpecVersion[] = ['draft']; description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). @@ -963,9 +955,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments // ─── A7: Missing Input Response ────────────────────────────────────────────── -export class InputRequiredResultMissingInputResponseScenario - implements ClientScenario -{ +export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { name = 'input-required-result-missing-input-response'; specVersions: SpecVersion[] = ['draft']; description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). @@ -1039,9 +1029,7 @@ Use the same tool as A1: \`test_input_required_result_elicitation\`. // ─── A9: Non-Tool Request (prompts/get) ────────────────────────────────────── -export class InputRequiredResultNonToolRequestScenario - implements ClientScenario -{ +export class InputRequiredResultNonToolRequestScenario implements ClientScenario { name = 'input-required-result-non-tool-request'; specVersions: SpecVersion[] = ['draft']; description = `Test InputRequiredResult on a non-tool request (prompts/get) to verify InputRequiredResult is universal (SEP-2322). diff --git a/src/scenarios/server/sep-2322-mrtr.test.ts b/src/scenarios/server/mrtr.test.ts similarity index 88% rename from src/scenarios/server/sep-2322-mrtr.test.ts rename to src/scenarios/server/mrtr.test.ts index 947a029c..d67750ea 100644 --- a/src/scenarios/server/sep-2322-mrtr.test.ts +++ b/src/scenarios/server/mrtr.test.ts @@ -18,11 +18,6 @@ import { InputRequiredResultMissingInputResponseScenario, InputRequiredResultNonToolRequestScenario } from './input-required-result'; -import { - InputRequiredResultTaskBasicScenario, - InputRequiredResultTaskBadInputResponseScenario, - InputRequiredResultTaskInputResponseInputRequiredScenario -} from './input-required-result-tasks'; function startServer(scriptPath: string, port: number): Promise { return new Promise((resolve, reject) => { @@ -95,10 +90,7 @@ describe('SEP-2322 MRTR positive tests', () => { new InputRequiredResultMultipleInputRequestsScenario(), new InputRequiredResultMultiRoundScenario(), new InputRequiredResultMissingInputResponseScenario(), - new InputRequiredResultNonToolRequestScenario(), - new InputRequiredResultTaskBasicScenario(), - new InputRequiredResultTaskBadInputResponseScenario(), - new InputRequiredResultTaskInputResponseInputRequiredScenario() + new InputRequiredResultNonToolRequestScenario() ]; for (const scenario of scenarios) { From d22b308a9521e202c5d3baebd2fd117a5dfda421 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 11:05:32 -0700 Subject: [PATCH 05/13] formatting --- src/scenarios/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/scenarios/index.ts b/src/scenarios/index.ts index b694b552..812b6faa 100644 --- a/src/scenarios/index.ts +++ b/src/scenarios/index.ts @@ -76,8 +76,6 @@ import { InputRequiredResultNonToolRequestScenario } from './server/input-required-result'; - - import { authScenariosList, backcompatScenariosList, From 9e3f2d8eeaf2811c744b7907e62216934cfb74bd Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 11:19:01 -0700 Subject: [PATCH 06/13] fix source missing issue --- src/scenarios/server/input-required-result.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 2afe9457..34a15268 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -6,7 +6,7 @@ * clients retry with inputResponses and echoed requestState. */ -import { ClientScenario, ConformanceCheck, SpecVersion } from '../../types'; +import { ClientScenario, ConformanceCheck, DRAFT_PROTOCOL_VERSION, SpecVersion } from '../../types'; import { createRawSession } from './client-helper'; import { isInputRequiredResult, @@ -21,6 +21,7 @@ import { export class InputRequiredResultBasicElicitationScenario implements ClientScenario { name = 'input-required-result-basic-elicitation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). @@ -176,6 +177,7 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments export class InputRequiredResultBasicSamplingScenario implements ClientScenario { name = 'input-required-result-basic-sampling'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). @@ -318,6 +320,7 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req export class InputRequiredResultBasicListRootsScenario implements ClientScenario { name = 'input-required-result-basic-list-roots'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). @@ -454,6 +457,7 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r export class InputRequiredResultRequestStateScenario implements ClientScenario { name = 'input-required-result-request-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test that requestState is correctly round-tripped in ephemeral InputRequiredResult flow (SEP-2322). @@ -597,6 +601,7 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { name = 'input-required-result-multiple-input-requests'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). @@ -772,6 +777,7 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume export class InputRequiredResultMultiRoundScenario implements ClientScenario { name = 'input-required-result-multi-round'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test multi-round ephemeral InputRequiredResult flow with evolving requestState (SEP-2322). @@ -957,6 +963,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { name = 'input-required-result-missing-input-response'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). @@ -1031,6 +1038,7 @@ Use the same tool as A1: \`test_input_required_result_elicitation\`. export class InputRequiredResultNonToolRequestScenario implements ClientScenario { name = 'input-required-result-non-tool-request'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; specVersions: SpecVersion[] = ['draft']; description = `Test InputRequiredResult on a non-tool request (prompts/get) to verify InputRequiredResult is universal (SEP-2322). From 038fef5b08aeaf8b7f777fc85faeeb673034f5de Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 11:44:11 -0700 Subject: [PATCH 07/13] remove mcp-session-id --- .../typescript/sep-2322-mrtr-server.ts | 1081 +++++++---------- .../servers/typescript/sep-2322-no-mrtr.ts | 152 --- src/scenarios/server/client-helper.ts | 74 +- .../server/input-required-result-helpers.ts | 4 +- src/scenarios/server/input-required-result.ts | 7 +- 5 files changed, 460 insertions(+), 858 deletions(-) delete mode 100644 examples/servers/typescript/sep-2322-no-mrtr.ts diff --git a/examples/servers/typescript/sep-2322-mrtr-server.ts b/examples/servers/typescript/sep-2322-mrtr-server.ts index ea416b2e..be131b1c 100644 --- a/examples/servers/typescript/sep-2322-mrtr-server.ts +++ b/examples/servers/typescript/sep-2322-mrtr-server.ts @@ -1,60 +1,20 @@ #!/usr/bin/env node /** - * SEP-2322 MRTR Reference Server + * SEP-2322 MRTR Reference Server (Stateless, SEP-2575 pattern) + * + * No session IDs, no initialize handshake. Each request carries _meta with + * protocolVersion, clientInfo, clientCapabilities. Implements server/discover. */ -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { - StreamableHTTPServerTransport, - EventStore, - EventId, - StreamId -} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - ListToolsRequestSchema, - ListPromptsRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - CancelTaskRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; import express from 'express'; import { randomUUID } from 'crypto'; -import { z } from 'zod'; interface InputRequest { method: string; params?: Record; } -const ExtendedCallToolRequestSchema = z.object({ - method: z.literal('tools/call'), - params: z - .object({ - name: z.string(), - arguments: z.record(z.string(), z.unknown()).optional(), - task: z.object({ ttl: z.number().optional() }).passthrough().optional(), - _meta: z.record(z.string(), z.unknown()).optional() - }) - .passthrough() -}); - -const ExtendedGetPromptRequestSchema = z.object({ - method: z.literal('prompts/get'), - params: z - .object({ - name: z.string(), - arguments: z.record(z.string(), z.unknown()).optional(), - _meta: z.record(z.string(), z.unknown()).optional() - }) - .passthrough() -}); - -const TasksInputResponseRequestSchema = z.object({ - method: z.literal('tasks/input_response'), - params: z.object({}).passthrough() -}); - type TaskKind = 'basic' | 'multi'; type TaskStatus = @@ -129,744 +89,533 @@ function getInputText(inputResponse: unknown, field: string): string { return typeof value === 'string' ? value : 'unknown'; } -class InMemoryEventStore implements EventStore { - private events: Map = - new Map(); - private counter = 0; +// --- JSON-RPC dispatch --- - async storeEvent(streamId: StreamId, message: string): Promise { - const id = String(++this.counter); - this.events.set(id, { streamId, message }); - return id; - } +type Handler = (params: Record) => unknown | Promise; - async replayEventsAfter( - lastEventId: EventId, - { send }: { send: (eventId: EventId, message: string) => Promise } - ): Promise { - const startId = parseInt(lastEventId, 10); - for (const [id, event] of this.events) { - if (parseInt(id, 10) > startId) { - await send(id, event.message); - } +const handlers: Record = {}; + +handlers['server/discover'] = () => ({ + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { + tools: {}, + prompts: {}, + elicitation: {}, + tasks: { + list: {}, + cancel: {}, + requests: { tools: { call: {} } } } - return ''; - } -} + }, + serverInfo: { name: 'sep-2322-mrtr-server', version: '1.0.0' } +}); -function createServer(): Server { - const server = new Server( - { name: 'sep-2322-mrtr-server', version: '1.0.0' }, +handlers['tools/list'] = () => ({ + tools: [ { - capabilities: { - tools: {}, - prompts: {}, - elicitation: {}, - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - call: {} - } - } - } - } + name: 'test_input_required_result_elicitation', + description: 'Test tool: returns InputRequiredResult with elicitation request', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_sampling', + description: 'Test tool: returns InputRequiredResult with sampling request', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_list_roots', + description: 'Test tool: returns InputRequiredResult with list roots request', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_request_state', + description: 'Test tool: returns InputRequiredResult with requestState', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_multiple_inputs', + description: 'Test tool: returns InputRequiredResult with multiple input requests', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_multi_round', + description: 'Test tool: returns InputRequiredResult across multiple rounds', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_task', + description: 'Test tool: task-based InputRequiredResult workflow', + inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_task_multi_input', + description: 'Test tool: task-based multi-round InputRequiredResult', + inputSchema: { type: 'object' as const, properties: {} } } - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test_input_required_result_elicitation', - description: - 'Test tool: returns InputRequiredResult with elicitation request', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_sampling', - description: - 'Test tool: returns InputRequiredResult with sampling request', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_list_roots', - description: - 'Test tool: returns InputRequiredResult with list roots request', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_request_state', - description: 'Test tool: returns InputRequiredResult with requestState', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_multiple_inputs', - description: - 'Test tool: returns InputRequiredResult with multiple input requests', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_multi_round', - description: - 'Test tool: returns InputRequiredResult across multiple rounds', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_task', - description: 'Test tool: task-based InputRequiredResult workflow', - inputSchema: { type: 'object' as const, properties: {} } - }, - { - name: 'test_input_required_result_task_multi_input', - description: 'Test tool: task-based multi-round InputRequiredResult', - inputSchema: { type: 'object' as const, properties: {} } - } - ] - })); - - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [ - { - name: 'test_input_required_result_prompt', - description: - 'Test prompt: returns InputRequiredResult with elicitation request' - } - ] - })); + ] +}); - server.setRequestHandler(ExtendedGetPromptRequestSchema, async (request) => { - const params = request.params as Record; - if (params.name !== 'test_input_required_result_prompt') { - throw new Error(`Unknown prompt: ${params.name}`); +handlers['prompts/list'] = () => ({ + prompts: [ + { + name: 'test_input_required_result_prompt', + description: 'Test prompt: returns InputRequiredResult with elicitation request' } + ] +}); - const inputResponses = params.inputResponses as - | Record - | undefined; +handlers['prompts/get'] = (params) => { + if (params.name !== 'test_input_required_result_prompt') { + throw { code: -32602, message: `Unknown prompt: ${params.name}` }; + } - if (inputResponses?.['user_context']) { - const context = getInputText(inputResponses['user_context'], 'context'); - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Prompt with context: ${context}` - } - } - ] - }; - } + const inputResponses = params.inputResponses as Record | undefined; + if (inputResponses?.['user_context']) { + const context = getInputText(inputResponses['user_context'], 'context'); return { - 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'] - } + messages: [ + { + role: 'user', + content: { type: 'text', text: `Prompt with context: ${context}` } + } + ] + }; + } + + return { + 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'] } } } - }; - }); - - server.setRequestHandler(ExtendedCallToolRequestSchema, async (request) => { - const params = request.params as Record; - const toolName = params.name as string; - const inputResponses = params.inputResponses as - | Record - | undefined; - const requestState = params.requestState as string | undefined; - - switch (toolName) { - case 'test_input_required_result_elicitation': { - if (inputResponses?.['user_name']) { - const name = getInputText(inputResponses['user_name'], 'name'); - return { - content: [{ type: 'text', text: `Hello, ${name}!` }] - }; + } + }; +}; + +handlers['tools/call'] = (params) => { + const toolName = params.name as string; + const inputResponses = params.inputResponses as Record | undefined; + const requestState = params.requestState as string | undefined; + + switch (toolName) { + case 'test_input_required_result_elicitation': { + if (inputResponses?.['user_name']) { + const name = getInputText(inputResponses['user_name'], 'name'); + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + return { + resultType: 'input_required', + 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_sampling': { + if (inputResponses?.['sample_request']) { + const sample = inputResponses['sample_request'] as Record; + const content = sample.content as Record | undefined; return { - resultType: 'input_required', - inputRequests: { - user_name: { - method: 'elicitation/create', - params: { - message: 'What is your name?', - requestedSchema: { - type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'] - } - } + content: [ + { + type: 'text', + text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}` } - } + ] }; } + return { + 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 + } + } + } + }; + } - case '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 { - content: [ - { - type: 'text', - text: `Sampling result: ${typeof content?.text === 'string' ? content.text : 'no response'}` - } - ] - }; + case 'test_input_required_result_list_roots': { + if (inputResponses?.['roots_request']) { + const rootsResult = inputResponses['roots_request'] as Record; + const roots = Array.isArray(rootsResult.roots) ? rootsResult.roots : []; + return { content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] }; + } + return { + resultType: 'input_required', + inputRequests: { + roots_request: { method: 'roots/list', params: {} } } + }; + } - return { - 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 + case '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 { content: [{ type: 'text', text: 'state-ok: requestState validated' }] }; + } + } + return { + 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() }) + }; + } - case '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 - : []; + case '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 name = getInputText(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; + const roots = Array.isArray(rootsResult.roots) ? rootsResult.roots : []; return { - content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] + content: [{ type: 'text', text: `Name: ${name}; Greeting: ${greeting}; Roots: ${roots.length}` }] }; } - - return { - resultType: 'input_required', - inputRequests: { - roots_request: { - method: 'roots/list', - params: {} - } - } - }; } - - case '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 { - content: [ - { type: 'text', text: 'state-ok: requestState validated' } - ] - }; - } - } - - return { - resultType: 'input_required', - inputRequests: { - confirm: { - method: 'elicitation/create', - params: { - message: 'Please confirm', - requestedSchema: { - type: 'object', - properties: { ok: { type: 'boolean' } }, - required: ['ok'] - } + return { + resultType: 'input_required', + inputRequests: { + user_name: { + method: 'elicitation/create', + params: { + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] } } }, - requestState: JSON.stringify({ - kind: 'request-state', - nonce: randomUUID() - }) - }; - } - - case '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 name = getInputText(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 { - content: [ - { - type: 'text', - text: `Name: ${name}; Greeting: ${greeting}; Roots: ${roots.length}` - } - ] - }; - } - } + 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() }) + }; + } + case 'test_input_required_result_multi_round': { + if (!requestState) { return { resultType: 'input_required', inputRequests: { - user_name: { + step1: { method: 'elicitation/create', params: { - message: 'What is your name?', + message: 'Step 1: 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() - }) + requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) }; } - case 'test_input_required_result_multi_round': { - if (!requestState) { - return { - 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 name = getInputText(inputResponses['step1'], 'name'); - return { - 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, - nonce: randomUUID() - }) - }; - } - - if (state.round === 2 && inputResponses?.['step2']) { - const name = typeof state.name === 'string' ? state.name : 'friend'; - const color = getInputText(inputResponses['step2'], 'color'); - return { - content: [ - { - type: 'text', - text: `Multi-round complete for ${name} who likes ${color}` - } - ] - }; - } - + const state = JSON.parse(requestState) as Record; + if (state.round === 1 && inputResponses?.['step1']) { + const name = getInputText(inputResponses['step1'], 'name'); return { resultType: 'input_required', inputRequests: { - step1: { + step2: { method: 'elicitation/create', params: { - message: 'Step 1: What is your name?', + message: 'Step 2: What is your favorite color?', requestedSchema: { type: 'object', - properties: { name: { type: 'string' } }, - required: ['name'] + properties: { color: { type: 'string' } }, + required: ['color'] } } } }, - requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) + requestState: JSON.stringify({ round: 2, name, nonce: randomUUID() }) }; } - case 'test_input_required_result_task': { - const taskMeta = params.task as Record | undefined; - if (!taskMeta) { - return { - content: [ - { - type: 'text', - text: 'Call with task metadata for task workflow' - } - ] - }; - } - - const task = createTask( - 'basic', - typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined - ); - task.inputRequests = { - user_input: { - method: 'elicitation/create', - params: { - message: 'What input should the task use?', - requestedSchema: { - type: 'object', - properties: { input: { type: 'string' } }, - required: ['input'] - } - } - } + if (state.round === 2 && inputResponses?.['step2']) { + const name = typeof state.name === 'string' ? state.name : 'friend'; + const color = getInputText(inputResponses['step2'], 'color'); + return { + content: [{ type: 'text', text: `Multi-round complete for ${name} who likes ${color}` }] }; - tasks.set(task.taskId, task); - - setTimeout(() => { - const current = tasks.get(task.taskId); - if (current?.status === 'working') { - updateTask(current, { status: 'input_required' }); - } - }, 100); - - return { task: taskView(task) }; } - case 'test_input_required_result_task_multi_input': { - const taskMeta = params.task as Record | undefined; - if (!taskMeta) { - return { - content: [{ type: 'text', text: 'Call with task metadata' }] - }; - } - - const task = createTask( - 'multi', - typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined - ); - task.inputRequests = { - first_input: { + return { + resultType: 'input_required', + inputRequests: { + step1: { method: 'elicitation/create', params: { - message: 'First input needed', + message: 'Step 1: What is your name?', requestedSchema: { type: 'object', - properties: { input: { type: 'string' } }, - required: ['input'] + properties: { name: { type: 'string' } }, + required: ['name'] } } } - }; - tasks.set(task.taskId, task); - - setTimeout(() => { - const current = tasks.get(task.taskId); - if (current?.status === 'working') { - updateTask(current, { status: 'input_required' }); - } - }, 100); - - return { task: taskView(task) }; - } - - default: - throw new Error(`Unknown tool: ${toolName}`); - } - }); - - server.setRequestHandler(GetTaskRequestSchema, async (request) => { - const taskId = request.params?.taskId as string; - const task = tasks.get(taskId); - if (!task) { - throw new Error(`Unknown task: ${taskId}`); - } - - return taskView(task); - }); - - server.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => { - const taskId = request.params?.taskId as string; - const task = tasks.get(taskId); - if (!task) { - throw new Error(`Unknown task: ${taskId}`); - } - - if (task.status === 'input_required') { - return { - resultType: 'input_required', - inputRequests: task.inputRequests + }, + requestState: JSON.stringify({ round: 1, nonce: randomUUID() }) }; } - if (task.status === 'completed') { - return { - content: [ - { - type: 'text', - text: task.finalContent ?? 'Task completed' + case 'test_input_required_result_task': { + const taskMeta = params.task as Record | undefined; + if (!taskMeta) { + return { content: [{ type: 'text', text: 'Call with task metadata for task workflow' }] }; + } + + const task = createTask('basic', typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined); + task.inputRequests = { + user_input: { + method: 'elicitation/create', + params: { + message: 'What input should the task use?', + requestedSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] + } } - ] + } }; - } + tasks.set(task.taskId, task); - return { status: task.status }; - }); - - server.setRequestHandler(TasksInputResponseRequestSchema, async (request) => { - const params = request.params as Record; - const meta = params._meta as Record | undefined; - const relatedTask = meta?.['io.modelcontextprotocol/related-task'] as - | Record - | undefined; - const taskId = relatedTask?.taskId as string | undefined; - const inputResponses = params.inputResponses as - | Record - | undefined; - - if (!taskId) { - throw new Error('Missing related task metadata'); - } + setTimeout(() => { + const current = tasks.get(task.taskId); + if (current?.status === 'working') { + updateTask(current, { status: 'input_required' }); + } + }, 100); - const task = tasks.get(taskId); - if (!task) { - throw new Error(`Unknown task: ${taskId}`); + return { task: taskView(task) }; } - const expectedKeys = Object.keys(task.inputRequests ?? {}); - const providedKeys = Object.keys(inputResponses ?? {}); - const hasAllExpected = - expectedKeys.length > 0 && - expectedKeys.every((key) => providedKeys.includes(key)); - - if (!hasAllExpected) { - updateTask(task, { status: 'input_required' }); - return ackResult(taskId); - } + case 'test_input_required_result_task_multi_input': { + const taskMeta = params.task as Record | undefined; + if (!taskMeta) { + return { content: [{ type: 'text', text: 'Call with task metadata' }] }; + } - if (task.kind === 'multi' && task.inputRound === 0) { - task.inputRound = 1; - updateTask(task, { - status: 'input_required', - inputRequests: { - second_input: { - method: 'elicitation/create', - params: { - message: 'Second input needed', - requestedSchema: { - type: 'object', - properties: { input: { type: 'string' } }, - required: ['input'] - } + const task = createTask('multi', typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined); + task.inputRequests = { + first_input: { + method: 'elicitation/create', + params: { + message: 'First input needed', + requestedSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] } } } - }); + }; + tasks.set(task.taskId, task); - return { - resultType: 'input_required', - inputRequests: task.inputRequests, - _meta: { - 'io.modelcontextprotocol/related-task': { taskId } + setTimeout(() => { + const current = tasks.get(task.taskId); + if (current?.status === 'working') { + updateTask(current, { status: 'input_required' }); } - }; - } + }, 100); - if (task.kind === 'basic') { - const userInput = getInputText(inputResponses?.['user_input'], 'input'); - updateTask(task, { - status: 'completed', - finalContent: `Task completed with input: ${userInput}` - }); - } else { - const finalInput = getInputText( - inputResponses?.['second_input'], - 'input' - ); - updateTask(task, { - status: 'completed', - finalContent: `Task completed after second input: ${finalInput}` - }); + return { task: taskView(task) }; } - return ackResult(taskId); - }); + default: + throw { code: -32601, message: `Unknown tool: ${toolName}` }; + } +}; + +handlers['tasks/get'] = (params) => { + const taskId = params.taskId as string; + const task = tasks.get(taskId); + if (!task) throw { code: -32602, message: `Unknown task: ${taskId}` }; + return taskView(task); +}; + +handlers['tasks/result'] = (params) => { + const taskId = params.taskId as string; + const task = tasks.get(taskId); + if (!task) throw { code: -32602, message: `Unknown task: ${taskId}` }; + + if (task.status === 'input_required') { + return { resultType: 'input_required', inputRequests: task.inputRequests }; + } + if (task.status === 'completed') { + return { content: [{ type: 'text', text: task.finalContent ?? 'Task completed' }] }; + } + return { status: task.status }; +}; - server.setRequestHandler(CancelTaskRequestSchema, async (request) => { - const taskId = request.params?.taskId as string; - const task = tasks.get(taskId); - if (!task) { - throw new Error(`Unknown task: ${taskId}`); - } +handlers['tasks/input_response'] = (params) => { + const meta = params._meta as Record | undefined; + const relatedTask = meta?.['io.modelcontextprotocol/related-task'] as Record | undefined; + const taskId = relatedTask?.taskId as string | undefined; + const inputResponses = params.inputResponses as Record | undefined; - updateTask(task, { status: 'cancelled' }); - return { acknowledged: true }; - }); + if (!taskId) throw { code: -32602, message: 'Missing related task metadata' }; - return server; -} + const task = tasks.get(taskId); + if (!task) throw { code: -32602, message: `Unknown task: ${taskId}` }; -const app = express(); -app.use(express.json()); - -const sessionTransports: { - [sessionId: string]: StreamableHTTPServerTransport; -} = {}; -const sessionServers: { [sessionId: string]: Server } = {}; + const expectedKeys = Object.keys(task.inputRequests ?? {}); + const providedKeys = Object.keys(inputResponses ?? {}); + const hasAllExpected = expectedKeys.length > 0 && expectedKeys.every((key) => providedKeys.includes(key)); -function isInitializeRequest(body: unknown): boolean { - if (Array.isArray(body)) { - return body.some( - (msg: Record) => msg.method === 'initialize' - ); + if (!hasAllExpected) { + updateTask(task, { status: 'input_required' }); + return ackResult(taskId); } - return (body as Record)?.method === 'initialize'; -} -app.post('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (task.kind === 'multi' && task.inputRound === 0) { + task.inputRound = 1; + updateTask(task, { + status: 'input_required', + inputRequests: { + second_input: { + method: 'elicitation/create', + params: { + message: 'Second input needed', + requestedSchema: { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] + } + } + } + } + }); + return { + resultType: 'input_required', + inputRequests: task.inputRequests, + _meta: { 'io.modelcontextprotocol/related-task': { taskId } } + }; + } - if (sessionId && sessionTransports[sessionId]) { - await sessionTransports[sessionId].handleRequest(req, res, req.body); - return; + if (task.kind === 'basic') { + const userInput = getInputText(inputResponses?.['user_input'], 'input'); + updateTask(task, { status: 'completed', finalContent: `Task completed with input: ${userInput}` }); + } else { + const finalInput = getInputText(inputResponses?.['second_input'], 'input'); + updateTask(task, { status: 'completed', finalContent: `Task completed after second input: ${finalInput}` }); } - if (!sessionId && isInitializeRequest(req.body)) { - const eventStore = new InMemoryEventStore(); - const server = createServer(); + return ackResult(taskId); +}; - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, - onsessioninitialized: (sid: string) => { - sessionTransports[sid] = transport; - sessionServers[sid] = server; - } - }); +handlers['tasks/cancel'] = (params) => { + const taskId = params.taskId as string; + const task = tasks.get(taskId); + if (!task) throw { code: -32602, message: `Unknown task: ${taskId}` }; + updateTask(task, { status: 'cancelled' }); + return { acknowledged: true }; +}; - transport.onclose = () => { - const sid = transport.sessionId; - if (sid) { - delete sessionTransports[sid]; - delete sessionServers[sid]; - } - }; +// --- Express app --- + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + const body = req.body; - await server.connect(transport); - await transport.handleRequest(req, res, req.body); + if (!body || !body.jsonrpc || body.jsonrpc !== '2.0') { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32600, message: 'Invalid JSON-RPC request' }, + id: null + }); return; } - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session' }, - id: null - }); -}); + const { id, method, params } = body; -app.get('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && sessionTransports[sessionId]) { - await sessionTransports[sessionId].handleRequest(req, res); + const handler = handlers[method]; + if (!handler) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32601, message: `Method not found: ${method}` }, + id: id ?? null + }); return; } - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session' }, - id: null - }); -}); -app.delete('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && sessionTransports[sessionId]) { - await sessionTransports[sessionId].handleRequest(req, res); - return; + try { + const result = await handler(params ?? {}); + res.json({ jsonrpc: '2.0', id, result }); + } catch (err: unknown) { + const rpcErr = err as { code?: number; message?: string }; + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: rpcErr.code ?? -32000, + message: rpcErr.message ?? 'Internal error' + }, + id: id ?? null + }); } - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session' }, - id: null - }); }); const PORT = parseInt(process.env.PORT || '3010', 10); app.listen(PORT, () => { - console.log( - `SEP-2322 MRTR reference server running on http://localhost:${PORT}/mcp` - ); -}); + console.log(`SEP-2322 MRTR reference server running on http://localhost:${PORT}/mcp`); +}); \ No newline at end of file diff --git a/examples/servers/typescript/sep-2322-no-mrtr.ts b/examples/servers/typescript/sep-2322-no-mrtr.ts deleted file mode 100644 index 5f9d9f92..00000000 --- a/examples/servers/typescript/sep-2322-no-mrtr.ts +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env node - -/** - * SEP-2322 Negative Test Server - * - * This server advertises the same tools as the MRTR reference server but - * returns normal complete results instead of InputRequiredResult. This lets - * negative tests verify that the conformance checks correctly emit FAILURE - * when a server doesn't actually implement the MRTR flow. - */ - -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { - StreamableHTTPServerTransport, - EventStore, - EventId, - StreamId -} from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { - CallToolRequestSchema, - ListToolsRequestSchema, - ListPromptsRequestSchema, - GetPromptRequestSchema -} from '@modelcontextprotocol/sdk/types.js'; -import express from 'express'; -import { randomUUID } from 'crypto'; - -// ─── In-Memory Event Store for SSE ────────────────────────────────────────── - -class InMemoryEventStore implements EventStore { - private events: Map = - new Map(); - private counter = 0; - - async storeEvent(streamId: StreamId, message: string): Promise { - const id = String(++this.counter); - this.events.set(id, { streamId: streamId as string, message }); - return id as EventId; - } - - async replayEventsAfter( - lastEventId: EventId, - { send }: { send: (eventId: EventId, message: string) => void } - ): Promise { - const start = parseInt(lastEventId as string, 10) || 0; - for (const [id, evt] of this.events) { - if (parseInt(id, 10) > start) { - send(id as EventId, evt.message); - } - } - return ( - this.events.size > 0 ? String(this.counter) : (lastEventId as string) - ) as string; - } -} - -function createServer(): Server { - const server = new Server( - { name: 'sep-2322-no-mrtr', version: '1.0.0' }, - { - capabilities: { - tools: {}, - prompts: {} - } - } - ); - - // ─── Tools: list ──────────────────────────────────────────────────── - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test_input_required_result_elicitation', - description: 'Returns a normal result (no MRTR)', - inputSchema: { type: 'object' as const, properties: {} } - } - ] - })); - - // ─── Tools: call — always returns a complete result ───────────────── - server.setRequestHandler(CallToolRequestSchema, async () => ({ - content: [{ type: 'text', text: 'Done (no input required)' }] - })); - - // ─── Prompts: list ────────────────────────────────────────────────── - server.setRequestHandler(ListPromptsRequestSchema, async () => ({ - prompts: [ - { - name: 'test_input_required_result_prompt', - description: 'Returns a normal prompt result (no MRTR)' - } - ] - })); - - // ─── Prompts: get — always returns a complete result ──────────────── - server.setRequestHandler(GetPromptRequestSchema, async () => ({ - messages: [ - { - role: 'assistant' as const, - content: { type: 'text' as const, text: 'Normal response, no MRTR.' } - } - ] - })); - - return server; -} - -// ─── HTTP transport ──────────────────────────────────────────────────────── - -const PORT = parseInt(process.env.PORT || '3011', 10); -const app = express(); -app.use(express.json()); - -const transports = new Map(); - -app.all('/mcp', async (req, res) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - if (sessionId && transports.has(sessionId)) { - const transport = transports.get(sessionId)!; - await transport.handleRequest(req, res); - return; - } - - if (req.method === 'POST') { - const eventStore = new InMemoryEventStore(); - const server = createServer(); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, - onsessioninitialized: (sid) => { - transports.set(sid, transport); - } - }); - - transport.onclose = () => { - const sid = (transport as unknown as { sessionId?: string }).sessionId; - if (sid) transports.delete(sid); - }; - - await server.connect(transport); - await transport.handleRequest(req, res); - } else { - res.status(400).json({ error: 'No valid session' }); - } -}); - -app.listen(PORT, () => { - console.log( - `sep-2322-no-mrtr server running on http://localhost:${PORT}/mcp` - ); -}); diff --git a/src/scenarios/server/client-helper.ts b/src/scenarios/server/client-helper.ts index 1447eea0..7968df8c 100644 --- a/src/scenarios/server/client-helper.ts +++ b/src/scenarios/server/client-helper.ts @@ -4,10 +4,10 @@ * Provides two connection modes: * 1. SDK-based (connectToServer) — uses the MCP TypeScript SDK for standard * protocol operations. - * 2. Raw JSON-RPC (RawMcpSession) — uses undici HTTP for draft/experimental - * features that the SDK does not yet support. + * 2. Raw JSON-RPC (RawMcpSession) — uses stateless fetch for draft/experimental + * features (SEP-2575 pattern: no initialize, no session ID, _meta per request). * - * Both modes share the same SDK-based initialize handshake and session ID. + * Both modes share a common client identity. */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -16,7 +16,7 @@ import { LoggingMessageNotificationSchema, ProgressNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; -import { request } from 'undici'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; // ─── JSON-RPC Types ────────────────────────────────────────────────────────── @@ -68,12 +68,12 @@ export async function connectToServer( }; } -// ─── Raw JSON-RPC Session ──────────────────────────────────────────────────── +// ─── Raw JSON-RPC Session (Stateless, SEP-2575 pattern) ────────────────────── /** - * A raw MCP session for testing draft/experimental protocol features that the - * SDK does not yet support. Uses the SDK for the standard initialize handshake, - * then sends raw JSON-RPC over HTTP via undici for subsequent requests. + * A raw MCP session for testing draft/experimental protocol features. + * Uses stateless HTTP requests with _meta on every request (SEP-2575 pattern). + * No initialize handshake, no session ID. * * Usage: * const session = await createRawSession(serverUrl); @@ -82,26 +82,22 @@ export async function connectToServer( export class RawMcpSession { private nextId = 1; private serverUrl: string; - private connection: MCPClientConnection | null = null; - private sessionId: string | undefined = undefined; constructor(serverUrl: string) { this.serverUrl = serverUrl; } /** - * Initialize the MCP session using the SDK's connectToServer(), - * then extract the session ID for subsequent raw requests. + * Initialize the session. For stateless servers this is a no-op, + * but kept for API compatibility. */ async initialize(): Promise { - this.connection = await connectToServer(this.serverUrl); - this.sessionId = this.connection.transport.sessionId; + // Stateless: no handshake needed } /** - * Send a JSON-RPC request via raw HTTP. - * Automatically manages session ID and auto-incrementing JSON-RPC IDs. - * Handles both JSON and SSE response formats. + * Send a JSON-RPC request via raw HTTP (stateless, SEP-2575 pattern). + * Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities. */ async send( method: string, @@ -111,44 +107,48 @@ export class RawMcpSession { const headers: Record = { 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' + Accept: 'application/json', + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }; + + // Inject _meta into params per SEP-2575 + 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: {} + }, + ...(params?._meta as Record | undefined) + } }; const body = JSON.stringify({ jsonrpc: '2.0', id, method, - params + params: enrichedParams }); - const response = await request(this.serverUrl, { + const response = await fetch(this.serverUrl, { method: 'POST', headers, body }); - const contentType = response.headers['content-type'] ?? ''; - - // Handle SSE responses — parse the last JSON-RPC message from the stream - // Not doing proper handling of SSE here since none of the MRTR features under test currently require it. - // This can be expanded if necessary for new features. - if (contentType.includes('text/event-stream')) { - const text = await response.body.text(); - return parseSseResponse(text); - } - - // Handle direct JSON responses - return (await response.body.json()) as JsonRpcResponse; + return (await response.json()) as JsonRpcResponse; } /** - * Close the underlying SDK connection. + * Close the session. No-op for stateless sessions. */ async close(): Promise { - if (this.connection) { - await this.connection.close(); - this.connection = null; - } + // Stateless: nothing to close } } diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index 67dbac63..53a7afa3 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -46,8 +46,8 @@ export function isCompleteResult( result: Record | undefined ): boolean { if (!result) return false; - if (result.resultType === 'complete') return true; - return false; + if (result.resultType === 'input_required') return false; + return true; } /** diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 34a15268..5e8161ee 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -6,7 +6,12 @@ * clients retry with inputResponses and echoed requestState. */ -import { ClientScenario, ConformanceCheck, DRAFT_PROTOCOL_VERSION, SpecVersion } from '../../types'; +import { + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + SpecVersion +} from '../../types'; import { createRawSession } from './client-helper'; import { isInputRequiredResult, From 73cbc5983122490215dc095a705a6dacd1195ed6 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 11:45:35 -0700 Subject: [PATCH 08/13] fix linting issues --- .../typescript/sep-2322-mrtr-server.ts | 154 ++++++++++++++---- 1 file changed, 120 insertions(+), 34 deletions(-) diff --git a/examples/servers/typescript/sep-2322-mrtr-server.ts b/examples/servers/typescript/sep-2322-mrtr-server.ts index be131b1c..c1c6125a 100644 --- a/examples/servers/typescript/sep-2322-mrtr-server.ts +++ b/examples/servers/typescript/sep-2322-mrtr-server.ts @@ -114,17 +114,20 @@ handlers['tools/list'] = () => ({ tools: [ { name: 'test_input_required_result_elicitation', - description: 'Test tool: returns InputRequiredResult with elicitation request', + description: + 'Test tool: returns InputRequiredResult with elicitation request', inputSchema: { type: 'object' as const, properties: {} } }, { name: 'test_input_required_result_sampling', - description: 'Test tool: returns InputRequiredResult with sampling request', + description: + 'Test tool: returns InputRequiredResult with sampling request', inputSchema: { type: 'object' as const, properties: {} } }, { name: 'test_input_required_result_list_roots', - description: 'Test tool: returns InputRequiredResult with list roots request', + description: + 'Test tool: returns InputRequiredResult with list roots request', inputSchema: { type: 'object' as const, properties: {} } }, { @@ -134,12 +137,14 @@ handlers['tools/list'] = () => ({ }, { name: 'test_input_required_result_multiple_inputs', - description: 'Test tool: returns InputRequiredResult with multiple input requests', + description: + 'Test tool: returns InputRequiredResult with multiple input requests', inputSchema: { type: 'object' as const, properties: {} } }, { name: 'test_input_required_result_multi_round', - description: 'Test tool: returns InputRequiredResult across multiple rounds', + description: + 'Test tool: returns InputRequiredResult across multiple rounds', inputSchema: { type: 'object' as const, properties: {} } }, { @@ -159,7 +164,8 @@ handlers['prompts/list'] = () => ({ prompts: [ { name: 'test_input_required_result_prompt', - description: 'Test prompt: returns InputRequiredResult with elicitation request' + description: + 'Test prompt: returns InputRequiredResult with elicitation request' } ] }); @@ -169,7 +175,9 @@ handlers['prompts/get'] = (params) => { throw { code: -32602, message: `Unknown prompt: ${params.name}` }; } - const inputResponses = params.inputResponses as Record | undefined; + const inputResponses = params.inputResponses as + | Record + | undefined; if (inputResponses?.['user_context']) { const context = getInputText(inputResponses['user_context'], 'context'); @@ -203,7 +211,9 @@ handlers['prompts/get'] = (params) => { handlers['tools/call'] = (params) => { const toolName = params.name as string; - const inputResponses = params.inputResponses as Record | undefined; + const inputResponses = params.inputResponses as + | Record + | undefined; const requestState = params.requestState as string | undefined; switch (toolName) { @@ -232,7 +242,10 @@ handlers['tools/call'] = (params) => { case 'test_input_required_result_sampling': { if (inputResponses?.['sample_request']) { - const sample = inputResponses['sample_request'] as Record; + const sample = inputResponses['sample_request'] as Record< + string, + unknown + >; const content = sample.content as Record | undefined; return { content: [ @@ -250,7 +263,13 @@ handlers['tools/call'] = (params) => { method: 'sampling/createMessage', params: { messages: [ - { role: 'user', content: { type: 'text', text: 'What is the capital of France?' } } + { + role: 'user', + content: { + type: 'text', + text: 'What is the capital of France?' + } + } ], maxTokens: 100 } @@ -261,9 +280,14 @@ handlers['tools/call'] = (params) => { case 'test_input_required_result_list_roots': { if (inputResponses?.['roots_request']) { - const rootsResult = inputResponses['roots_request'] as Record; + const rootsResult = inputResponses['roots_request'] as Record< + string, + unknown + >; const roots = Array.isArray(rootsResult.roots) ? rootsResult.roots : []; - return { content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] }; + return { + content: [{ type: 'text', text: `Found ${roots.length} root(s)` }] + }; } return { resultType: 'input_required', @@ -276,9 +300,14 @@ handlers['tools/call'] = (params) => { case '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; + const ok = (inputResponses['confirm'] as Record) + ?.content as Record | undefined; if (state.kind === 'request-state' && ok?.ok === true) { - return { content: [{ type: 'text', text: 'state-ok: requestState validated' }] }; + return { + content: [ + { type: 'text', text: 'state-ok: requestState validated' } + ] + }; } } return { @@ -296,7 +325,10 @@ handlers['tools/call'] = (params) => { } } }, - requestState: JSON.stringify({ kind: 'request-state', nonce: randomUUID() }) + requestState: JSON.stringify({ + kind: 'request-state', + nonce: randomUUID() + }) }; } @@ -310,12 +342,27 @@ handlers['tools/call'] = (params) => { const state = JSON.parse(requestState) as Record; if (state.kind === 'multiple-inputs') { const name = getInputText(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; - const roots = Array.isArray(rootsResult.roots) ? rootsResult.roots : []; + 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 { - content: [{ type: 'text', text: `Name: ${name}; Greeting: ${greeting}; Roots: ${roots.length}` }] + content: [ + { + type: 'text', + text: `Name: ${name}; Greeting: ${greeting}; Roots: ${roots.length}` + } + ] }; } } @@ -336,13 +383,21 @@ handlers['tools/call'] = (params) => { greeting: { method: 'sampling/createMessage', params: { - messages: [{ role: 'user', content: { type: 'text', text: 'Generate a greeting' } }], + 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() }) + requestState: JSON.stringify({ + kind: 'multiple-inputs', + nonce: randomUUID() + }) }; } @@ -393,7 +448,12 @@ handlers['tools/call'] = (params) => { const name = typeof state.name === 'string' ? state.name : 'friend'; const color = getInputText(inputResponses['step2'], 'color'); return { - content: [{ type: 'text', text: `Multi-round complete for ${name} who likes ${color}` }] + content: [ + { + type: 'text', + text: `Multi-round complete for ${name} who likes ${color}` + } + ] }; } @@ -419,10 +479,17 @@ handlers['tools/call'] = (params) => { case 'test_input_required_result_task': { const taskMeta = params.task as Record | undefined; if (!taskMeta) { - return { content: [{ type: 'text', text: 'Call with task metadata for task workflow' }] }; + return { + content: [ + { type: 'text', text: 'Call with task metadata for task workflow' } + ] + }; } - const task = createTask('basic', typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined); + const task = createTask( + 'basic', + typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined + ); task.inputRequests = { user_input: { method: 'elicitation/create', @@ -454,7 +521,10 @@ handlers['tools/call'] = (params) => { return { content: [{ type: 'text', text: 'Call with task metadata' }] }; } - const task = createTask('multi', typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined); + const task = createTask( + 'multi', + typeof taskMeta.ttl === 'number' ? taskMeta.ttl : undefined + ); task.inputRequests = { first_input: { method: 'elicitation/create', @@ -501,16 +571,22 @@ handlers['tasks/result'] = (params) => { return { resultType: 'input_required', inputRequests: task.inputRequests }; } if (task.status === 'completed') { - return { content: [{ type: 'text', text: task.finalContent ?? 'Task completed' }] }; + return { + content: [{ type: 'text', text: task.finalContent ?? 'Task completed' }] + }; } return { status: task.status }; }; handlers['tasks/input_response'] = (params) => { const meta = params._meta as Record | undefined; - const relatedTask = meta?.['io.modelcontextprotocol/related-task'] as Record | undefined; + const relatedTask = meta?.['io.modelcontextprotocol/related-task'] as + | Record + | undefined; const taskId = relatedTask?.taskId as string | undefined; - const inputResponses = params.inputResponses as Record | undefined; + const inputResponses = params.inputResponses as + | Record + | undefined; if (!taskId) throw { code: -32602, message: 'Missing related task metadata' }; @@ -519,7 +595,9 @@ handlers['tasks/input_response'] = (params) => { const expectedKeys = Object.keys(task.inputRequests ?? {}); const providedKeys = Object.keys(inputResponses ?? {}); - const hasAllExpected = expectedKeys.length > 0 && expectedKeys.every((key) => providedKeys.includes(key)); + const hasAllExpected = + expectedKeys.length > 0 && + expectedKeys.every((key) => providedKeys.includes(key)); if (!hasAllExpected) { updateTask(task, { status: 'input_required' }); @@ -553,10 +631,16 @@ handlers['tasks/input_response'] = (params) => { if (task.kind === 'basic') { const userInput = getInputText(inputResponses?.['user_input'], 'input'); - updateTask(task, { status: 'completed', finalContent: `Task completed with input: ${userInput}` }); + updateTask(task, { + status: 'completed', + finalContent: `Task completed with input: ${userInput}` + }); } else { const finalInput = getInputText(inputResponses?.['second_input'], 'input'); - updateTask(task, { status: 'completed', finalContent: `Task completed after second input: ${finalInput}` }); + updateTask(task, { + status: 'completed', + finalContent: `Task completed after second input: ${finalInput}` + }); } return ackResult(taskId); @@ -617,5 +701,7 @@ app.post('/mcp', async (req, res) => { const PORT = parseInt(process.env.PORT || '3010', 10); app.listen(PORT, () => { - console.log(`SEP-2322 MRTR reference server running on http://localhost:${PORT}/mcp`); -}); \ No newline at end of file + console.log( + `SEP-2322 MRTR reference server running on http://localhost:${PORT}/mcp` + ); +}); From 76b88747936b4e7e9f449932296a659d93c58eb7 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 12:02:42 -0700 Subject: [PATCH 09/13] switch to using RPC --- .../server/input-required-result-helpers.ts | 60 +++++++++++++++++-- src/scenarios/server/input-required-result.ts | 50 ++++++---------- 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/scenarios/server/input-required-result-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts index 53a7afa3..18bff399 100644 --- a/src/scenarios/server/input-required-result-helpers.ts +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -1,14 +1,64 @@ /** * Helpers for SEP-2322 conformance tests. * - * Uses RawMcpSession from client-helper.ts for connection management and - * raw JSON-RPC transport. This file adds InputRequiredResult-specific type - * guards and mock response builders. + * Provides InputRequiredResult-specific type guards, mock response builders, + * and a stateless JSON-RPC transport helper. */ -import { RawMcpSession, JsonRpcResponse } from './client-helper'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; -export type { RawMcpSession, JsonRpcResponse }; +// ─── 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: {} + }, + ...(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 ─────────────────────────────────────────────── diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 5e8161ee..d645d385 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -12,8 +12,8 @@ import { DRAFT_PROTOCOL_VERSION, SpecVersion } from '../../types'; -import { createRawSession } from './client-helper'; import { + sendRpc, isInputRequiredResult, isCompleteResult, mockElicitResponse, @@ -69,10 +69,8 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Initial call — expect InputRequiredResult - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_elicitation', arguments: {} }); @@ -118,7 +116,7 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments // Round 2: Retry with inputResponses — expect complete result if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { - const r2 = await session.send('tools/call', { + const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_elicitation', arguments: {}, inputResponses: { @@ -216,10 +214,8 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Initial call - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_sampling', arguments: {} }); @@ -268,7 +264,7 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req // Round 2: Retry with inputResponses if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await session.send('tools/call', { + const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_sampling', arguments: {}, inputResponses: { @@ -353,10 +349,8 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Initial call - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_list_roots', arguments: {} }); @@ -405,7 +399,7 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r // Round 2: Retry with inputResponses if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await session.send('tools/call', { + const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_list_roots', arguments: {}, inputResponses: { @@ -498,10 +492,8 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_request_state', arguments: {} }); @@ -540,7 +532,7 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument // Round 2: Retry with inputResponses + requestState if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await session.send('tools/call', { + const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_request_state', arguments: {}, inputResponses: { @@ -653,10 +645,8 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multiple_inputs', arguments: {} }); @@ -727,7 +717,7 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume } } - const r2 = await session.send('tools/call', { + const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multiple_inputs', arguments: {}, inputResponses, @@ -840,10 +830,8 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multi_round', arguments: {} }); @@ -879,7 +867,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments // Round 2: Retry — expect another InputRequiredResult const r1InputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await session.send('tools/call', { + const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multi_round', arguments: {}, inputResponses: { @@ -922,7 +910,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments // Round 3: Final retry — expect complete result const r2InputKey = Object.keys(r2Result.inputRequests!)[0]; - const r3 = await session.send('tools/call', { + const r3 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multi_round', arguments: {}, inputResponses: { @@ -982,10 +970,8 @@ Use the same tool as A1: \`test_input_required_result_elicitation\`. const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Send wrong inputResponses (wrong key) - const r1 = await session.send('tools/call', { + const r1 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_elicitation', arguments: {}, inputResponses: { @@ -1078,10 +1064,8 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('prompts/get', { + const r1 = await sendRpc(serverUrl, 'prompts/get', { name: 'test_input_required_result_prompt' }); @@ -1111,7 +1095,7 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli // Round 2: Retry with inputResponses if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await session.send('prompts/get', { + const r2 = await sendRpc(serverUrl, 'prompts/get', { name: 'test_input_required_result_prompt', inputResponses: { [inputKey]: mockElicitResponse({ context: 'test context' }) From d1ea13ecc4df64029e8bb35e1567e50937673f06 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 12:11:29 -0700 Subject: [PATCH 10/13] remove negative tests, returning a complete result is normal behavior. --- src/scenarios/server/negative.test.ts | 31 --------------------------- 1 file changed, 31 deletions(-) diff --git a/src/scenarios/server/negative.test.ts b/src/scenarios/server/negative.test.ts index b03984ab..769374a5 100644 --- a/src/scenarios/server/negative.test.ts +++ b/src/scenarios/server/negative.test.ts @@ -2,7 +2,6 @@ import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { DNSRebindingProtectionScenario } from './dns-rebinding'; import { ResourcesNotFoundErrorScenario } from './resources'; -import { InputRequiredResultBasicElicitationScenario } from './input-required-result'; function startServer(scriptPath: string, port: number): Promise { return new Promise((resolve, reject) => { @@ -107,34 +106,4 @@ describe('Server scenario negative tests', () => { expect(errorCode?.status).toBe('WARNING'); }, 10000); }); - - describe('sep-2322-no-mrtr', () => { - let serverProcess: ChildProcess | null = null; - const PORT = 3011; - - beforeAll(async () => { - serverProcess = await startServer( - path.join( - process.cwd(), - 'examples/servers/typescript/sep-2322-no-mrtr.ts' - ), - PORT - ); - }, 35000); - - afterAll(async () => { - await stopServer(serverProcess); - }); - - it('emits FAILURE when server returns complete result instead of InputRequiredResult', async () => { - const scenario = new InputRequiredResultBasicElicitationScenario(); - const checks = await scenario.run(`http://localhost:${PORT}/mcp`); - - const incompleteCheck = checks.find( - (c) => c.id === 'input-required-result-elicitation-incomplete' - ); - expect(incompleteCheck).toBeDefined(); - expect(incompleteCheck?.status).toBe('FAILURE'); - }, 15000); - }); }); From bd1dc47bceedfe5f232f5f81cf2f61d8abae5adc Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 12:38:18 -0700 Subject: [PATCH 11/13] update client tests --- .../clients/typescript/everything-client.ts | 108 +++++ src/scenarios/client/mrtr-client.test.ts | 41 ++ src/scenarios/client/mrtr-client.ts | 418 ++++++++++++++++++ src/scenarios/index.ts | 4 + src/seps/sep-2322.yaml | 20 +- 5 files changed, 581 insertions(+), 10 deletions(-) create mode 100644 src/scenarios/client/mrtr-client.test.ts create mode 100644 src/scenarios/client/mrtr-client.ts diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index a9cf90cc..c2a583ca 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -726,6 +726,114 @@ 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'); + } + + logger.debug('MRTR client scenario completed'); +} + +registerScenario('mrtr-client-request-state', runMRTRClient); + // ============================================================================ // Main entry point // ============================================================================ diff --git a/src/scenarios/client/mrtr-client.test.ts b/src/scenarios/client/mrtr-client.test.ts new file mode 100644 index 00000000..a5fdbe22 --- /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 mrtr-client-request-state scenario', async () => { + const clientFn = getHandler('mrtr-client-request-state'); + if (!clientFn) { + throw new Error( + 'No handler registered for scenario: mrtr-client-request-state' + ); + } + + const scenario = getScenario('mrtr-client-request-state'); + if (!scenario) { + throw new Error('Scenario not found: mrtr-client-request-state'); + } + + const runner = new InlineClientRunner(clientFn); + await runClientAgainstScenario(runner, 'mrtr-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 00000000..98f53e48 --- /dev/null +++ b/src/scenarios/client/mrtr-client.ts @@ -0,0 +1,418 @@ +/** + * 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[] + } + } +]; + +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(); + + 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; + } + + 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 + }); + 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'); + + // Check 1: requestState must be present and unchanged + const stateErrors: string[] = []; + if (!requestState) { + stateErrors.push('Client did not include requestState in retry'); + } else { + try { + const parsed = JSON.parse(requestState) as Record; + if (parsed.originalId !== originalId) { + stateErrors.push( + `requestState was modified: originalId mismatch (expected ${originalId}, got ${parsed.originalId})` + ); + } + if (!parsed.nonce) { + stateErrors.push('requestState was modified: nonce missing'); + } + } catch { + stateErrors.push( + `requestState was modified or corrupted: cannot parse` + ); + } + } + + checks.push({ + id: 'mrtr-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: { + 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: 'mrtr-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: 'mrtr-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: 'mrtr-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' }] + } + }); + } + + return app; +} + +export class MRTRClientScenario implements Scenario { + name = 'mrtr-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 = [ + 'mrtr-client-request-state-echoed', + 'mrtr-client-jsonrpc-id-different', + 'mrtr-client-no-state-omitted', + 'mrtr-client-parallel-isolation' + ]; + + 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 73a47763..ae0f4fab 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'; @@ -258,6 +259,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/seps/sep-2322.yaml b/src/seps/sep-2322.yaml index 2f21e7cb..ec5ffc37 100644 --- a/src/seps/sep-2322.yaml +++ b/src/seps/sep-2322.yaml @@ -64,20 +64,20 @@ requirements: - text: 'client MUST construct the requested inputs before retrying the original request' excluded: 'Client behavior; not testable via server conformance tests' - - text: 'client MUST echo back the exact value of requestState when retrying' - excluded: 'Client behavior; not testable via server conformance tests' + - check: mrtr-client-request-state-echoed + text: 'client MUST echo back the exact value of requestState when retrying' - - text: 'Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents' - excluded: 'Client behavior; not testable via server conformance tests' + - check: mrtr-client-request-state-echoed + text: 'Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents' - - text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry' - excluded: 'Client behavior; not testable via server conformance tests' + - check: mrtr-client-no-state-omitted + text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry' - - text: 'The JSON-RPC id MUST be different between the initial request and the retry' - excluded: 'Client behavior; not testable via server conformance tests' + - check: mrtr-client-jsonrpc-id-different + text: 'The JSON-RPC id MUST be different between the initial request and the retry' - - text: 'inputRequests and requestState MUST NOT be used for any other request the client may be sending in parallel' - excluded: 'Client behavior; not testable via server conformance tests' + - check: mrtr-client-parallel-isolation + text: 'inputRequests and requestState MUST NOT be used for any other request the client may be sending in parallel' # ── Server Requirements (Tasks) ──────────────────────────────────────────── From 742a37c8587e2f58d0aae8711dd98d73b62c3247 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 12:39:19 -0700 Subject: [PATCH 12/13] style checks --- .../clients/typescript/everything-client.ts | 34 +++++++++++++++---- src/scenarios/client/mrtr-client.ts | 4 ++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index c2a583ca..c540fafe 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -736,7 +736,11 @@ async function runMRTRClient(serverUrl: string): Promise { async function sendRpc( method: string, params?: Record - ): Promise<{ id: number; result?: Record; error?: { code: number; message: string } }> { + ): Promise<{ + id: number; + result?: Record; + error?: { code: number; message: string }; + }> { const id = nextId++; const body: Record = { jsonrpc: '2.0', @@ -752,13 +756,21 @@ async function runMRTRClient(serverUrl: string): Promise { }); if (resp.status === 204) return { id, result: {} }; - return (await resp.json()) as { id: number; result?: Record; error?: { code: number; message: string } }; + 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)); + 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', { @@ -776,7 +788,10 @@ async function runMRTRClient(serverUrl: string): Promise { 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 } }; + inputResponses[key] = { + action: 'accept', + content: { confirmed: true } + }; } } @@ -785,7 +800,9 @@ async function runMRTRClient(serverUrl: string): Promise { name: 'test_mrtr_unrelated', arguments: {} }); - logger.debug('test_mrtr_unrelated: called without MRTR state (isolation check)'); + logger.debug( + 'test_mrtr_unrelated: called without MRTR state (isolation check)' + ); // Retry with inputResponses + requestState echoed back unchanged const retryParams: Record = { @@ -816,7 +833,10 @@ async function runMRTRClient(serverUrl: string): Promise { 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 } }; + inputResponses[key] = { + action: 'accept', + content: { confirmed: true } + }; } } diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index 98f53e48..4e3bb785 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -408,7 +408,9 @@ export class MRTRClientScenario implements Scenario { description: `MRTR client check: ${slug}`, status: 'FAILURE', timestamp: new Date().toISOString(), - details: { message: 'Tool was not called by client or MRTR flow not completed' }, + details: { + message: 'Tool was not called by client or MRTR flow not completed' + }, specReferences: MRTR_SPEC_REFERENCES }); } From 7c7c21cbbfc854f265074c3c93c5f255e13e7d23 Mon Sep 17 00:00:00 2001 From: Caitie McCaffrey Date: Wed, 20 May 2026 12:51:52 -0700 Subject: [PATCH 13/13] fixing CI issues --- src/scenarios/server/input-required-result.ts | 16 ++++++++-------- src/scenarios/server/lifecycle.test.ts | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index d645d385..9bb2b4b4 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -27,7 +27,7 @@ import { export class InputRequiredResultBasicElicitationScenario implements ClientScenario { name = 'input-required-result-basic-elicitation'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). **Server Implementation Requirements:** @@ -181,7 +181,7 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments export class InputRequiredResultBasicSamplingScenario implements ClientScenario { name = 'input-required-result-basic-sampling'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). **Server Implementation Requirements:** @@ -322,7 +322,7 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req export class InputRequiredResultBasicListRootsScenario implements ClientScenario { name = 'input-required-result-basic-list-roots'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). **Server Implementation Requirements:** @@ -457,7 +457,7 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r export class InputRequiredResultRequestStateScenario implements ClientScenario { name = 'input-required-result-request-state'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test that requestState is correctly round-tripped in ephemeral InputRequiredResult flow (SEP-2322). **Server Implementation Requirements:** @@ -599,7 +599,7 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { name = 'input-required-result-multiple-input-requests'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). **Server Implementation Requirements:** @@ -773,7 +773,7 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume export class InputRequiredResultMultiRoundScenario implements ClientScenario { name = 'input-required-result-multi-round'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test multi-round ephemeral InputRequiredResult flow with evolving requestState (SEP-2322). **Server Implementation Requirements:** @@ -957,7 +957,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { name = 'input-required-result-missing-input-response'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). **Server Implementation Requirements:** @@ -1030,7 +1030,7 @@ Use the same tool as A1: \`test_input_required_result_elicitation\`. export class InputRequiredResultNonToolRequestScenario implements ClientScenario { name = 'input-required-result-non-tool-request'; readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; - specVersions: SpecVersion[] = ['draft']; + 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:** diff --git a/src/scenarios/server/lifecycle.test.ts b/src/scenarios/server/lifecycle.test.ts index 15ee6d80..6292d110 100644 --- a/src/scenarios/server/lifecycle.test.ts +++ b/src/scenarios/server/lifecycle.test.ts @@ -15,6 +15,7 @@ describe('ServerInitializeScenario', () => { vi.stubGlobal('fetch', fetchMock); vi.mocked(connectToServer).mockResolvedValue({ client: {} as any, + transport: {} as any, close: closeMock }); });