diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index a9cf90cc..c540fafe 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -726,6 +726,134 @@ 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/examples/servers/typescript/sep-2322-mrtr-server.ts b/examples/servers/typescript/sep-2322-mrtr-server.ts new file mode 100644 index 00000000..c1c6125a --- /dev/null +++ b/examples/servers/typescript/sep-2322-mrtr-server.ts @@ -0,0 +1,707 @@ +#!/usr/bin/env node + +/** + * 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 express from 'express'; +import { randomUUID } from 'crypto'; + +interface InputRequest { + method: string; + params?: Record; +} + +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'; +} + +// --- JSON-RPC dispatch --- + +type Handler = (params: Record) => unknown | Promise; + +const handlers: Record = {}; + +handlers['server/discover'] = () => ({ + supportedVersions: ['DRAFT-2026-v1'], + capabilities: { + tools: {}, + prompts: {}, + elicitation: {}, + tasks: { + list: {}, + cancel: {}, + requests: { tools: { call: {} } } + } + }, + serverInfo: { name: 'sep-2322-mrtr-server', version: '1.0.0' } +}); + +handlers['tools/list'] = () => ({ + 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: {} } + } + ] +}); + +handlers['prompts/list'] = () => ({ + prompts: [ + { + name: 'test_input_required_result_prompt', + description: + 'Test prompt: returns InputRequiredResult with elicitation request' + } + ] +}); + +handlers['prompts/get'] = (params) => { + if (params.name !== 'test_input_required_result_prompt') { + throw { code: -32602, message: `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'] + } + } + } + } + }; +}; + +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< + 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 { 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 }; +}; + +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; + + if (!taskId) throw { code: -32602, message: 'Missing related task metadata' }; + + const task = tasks.get(taskId); + if (!task) throw { code: -32602, message: `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); +}; + +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 }; +}; + +// --- Express app --- + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + const body = 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; + } + + const { id, method, params } = body; + + 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; + } + + 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 + }); + } +}); + +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/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..4e3bb785 --- /dev/null +++ b/src/scenarios/client/mrtr-client.ts @@ -0,0 +1,420 @@ +/** + * 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 6a076e61..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'; @@ -66,23 +67,17 @@ 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'; - -import { - IncompleteResultTaskBasicScenario, - IncompleteResultTaskBadInputResponseScenario, - IncompleteResultTaskInputResponseIncompleteScenario -} from './server/incomplete-result-tasks'; + InputRequiredResultBasicElicitationScenario, + InputRequiredResultBasicSamplingScenario, + InputRequiredResultBasicListRootsScenario, + InputRequiredResultRequestStateScenario, + InputRequiredResultMultipleInputRequestsScenario, + InputRequiredResultMultiRoundScenario, + InputRequiredResultMissingInputResponseScenario, + InputRequiredResultNonToolRequestScenario +} from './server/input-required-result'; import { HttpHeaderValidationScenario, @@ -122,20 +117,24 @@ const pendingClientScenariosList: ClientScenario[] = [ new HttpCustomHeaderServerValidationScenario(), 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() + // HTTP Standardization (SEP-2243) + // Pending until the everything-server fully implements SEP-2243 + // header validation (case-insensitive names, whitespace trimming, -32001 error code) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario(), + new ServerSSEPollingScenario(), + + // 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() ]; // All client scenarios @@ -201,20 +200,20 @@ const allClientScenariosList: ClientScenario[] = [ new HttpCustomHeaderServerValidationScenario(), 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() + // HTTP Standardization scenarios (SEP-2243) + new HttpHeaderValidationScenario(), + new HttpCustomHeaderServerValidationScenario(), + new DNSRebindingProtectionScenario(), + + // InputRequiredResult scenarios (SEP-2322) + new InputRequiredResultBasicElicitationScenario(), + new InputRequiredResultBasicSamplingScenario(), + new InputRequiredResultBasicListRootsScenario(), + new InputRequiredResultRequestStateScenario(), + new InputRequiredResultMultipleInputRequestsScenario(), + new InputRequiredResultMultiRoundScenario(), + new InputRequiredResultMissingInputResponseScenario(), + new InputRequiredResultNonToolRequestScenario() ]; // Active client scenarios (excludes pending) @@ -260,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/scenarios/server/client-helper.ts b/src/scenarios/server/client-helper.ts index 5e99374c..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,24 +82,22 @@ export async function connectToServer( export class RawMcpSession { private nextId = 1; private serverUrl: string; - private connection: MCPClientConnection | null = null; 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); + // 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, @@ -109,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/incomplete-result-helpers.ts b/src/scenarios/server/incomplete-result-helpers.ts deleted file mode 100644 index e79189c0..00000000 --- a/src/scenarios/server/incomplete-result-helpers.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 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 - * guards and mock response builders. - */ - -import { RawMcpSession, JsonRpcResponse } from './client-helper'; - -export type { RawMcpSession, JsonRpcResponse }; - -// ─── IncompleteResult Types ────────────────────────────────────────────────── - -export interface IncompleteResult { - result_type?: 'incomplete'; - inputRequests?: Record; - requestState?: string; - _meta?: Record; - [key: string]: unknown; -} - -export interface InputRequestObject { - method: string; - params?: Record; -} - -// ─── Type Guards ───────────────────────────────────────────────────────────── - -/** - * Check if a JSON-RPC result is an IncompleteResult. - */ -export function isIncompleteResult( - result: Record | undefined -): result is IncompleteResult { - if (!result) return false; - if (result.result_type === 'incomplete') return true; - // Also detect by presence of IncompleteResult 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. - */ -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); -} - -/** - * Extract inputRequests from an IncompleteResult. - */ -export function getInputRequests( - result: IncompleteResult -): Record | undefined { - return result.inputRequests; -} - -// ─── Mock Response Builders ────────────────────────────────────────────────── - -/** - * Build a mock elicitation response (ElicitResult). - */ -export function mockElicitResponse( - content: Record -): Record { - return { - action: 'accept', - content - }; -} - -/** - * Build a mock sampling response (CreateMessageResult). - */ -export function mockSamplingResponse(text: string): Record { - return { - role: 'assistant', - content: { - type: 'text', - text - }, - model: 'test-model', - stopReason: 'endTurn' - }; -} - -/** - * Build a mock list roots response (ListRootsResult). - */ -export function mockListRootsResponse(): Record { - return { - roots: [ - { - uri: 'file:///test/root', - name: 'Test Root' - } - ] - }; -} - -// ─── Spec References ───────────────────────────────────────────────────────── - -/** - * SEP reference for IncompleteResult / MRTR tests. - */ -export const MRTR_SPEC_REFERENCES = [ - { - id: 'SEP-2322', - url: 'https://github.com/modelcontextprotocol/specification/pull/2322' - } -]; diff --git a/src/scenarios/server/incomplete-result-tasks.ts b/src/scenarios/server/incomplete-result-tasks.ts deleted file mode 100644 index c2c94883..00000000 --- a/src/scenarios/server/incomplete-result-tasks.ts +++ /dev/null @@ -1,639 +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 { - isIncompleteResult, - isCompleteResult, - mockElicitResponse, - MRTR_SPEC_REFERENCES, - RawMcpSession -} from './incomplete-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 IncompleteResultTaskBasicScenario implements ClientScenario { - name = 'incomplete-result-task-basic'; - specVersions: SpecVersion[] = ['draft']; - description = `Test full persistent IncompleteResult workflow via Tasks API (SEP-2322). - -**Server Implementation Requirements:** - -Implement a tool named \`test_incomplete_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\`: - -\`\`\`json -{ - "result_type": "incomplete", - "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_incomplete_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: 'incomplete-result-task-created', - name: 'IncompleteResultTaskCreated', - 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: 'incomplete-result-task-input-required', - name: 'IncompleteResultTaskInputRequired', - 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 (!isIncompleteResult(r3Result)) { - r3Errors.push( - 'Expected IncompleteResult with inputRequests from tasks/result' - ); - } else if (!r3Result.inputRequests) { - r3Errors.push( - 'IncompleteResult from tasks/result missing inputRequests' - ); - } - - checks.push({ - id: 'incomplete-result-task-tasks-result-incomplete', - name: 'IncompleteResultTaskTasksResultIncomplete', - description: 'tasks/result returns IncompleteResult 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 || !isIncompleteResult(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: 'incomplete-result-task-input-response-sent', - name: 'IncompleteResultTaskInputResponseSent', - 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: 'incomplete-result-task-ack-structure', - name: 'IncompleteResultTaskAckStructure', - 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: 'incomplete-result-task-completed', - name: 'IncompleteResultTaskCompleted', - 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: 'incomplete-result-task-final-result', - name: 'IncompleteResultTaskFinalResult', - 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: 'incomplete-result-task-created', - name: 'IncompleteResultTaskCreated', - 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 IncompleteResultTaskBadInputResponseScenario - implements ClientScenario -{ - name = 'incomplete-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\`. - -**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_incomplete_result_task', - arguments: {}, - task: { ttl: 30000 } - }); - - const task = r1.result?.task as { taskId?: string } | undefined; - if (!task?.taskId) { - checks.push({ - id: 'incomplete-result-task-bad-input-prereq', - name: 'IncompleteResultTaskBadInputPrereq', - 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 || - !isIncompleteResult(r3.result) || - !r3.result.inputRequests - ) { - checks.push({ - id: 'incomplete-result-task-bad-input-prereq', - name: 'IncompleteResultTaskBadInputPrereq', - 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 && - isIncompleteResult(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: 'incomplete-result-task-bad-input-rerequests', - name: 'IncompleteResultTaskBadInputRerequests', - 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: 'incomplete-result-task-bad-input-rerequests', - name: 'IncompleteResultTaskBadInputRerequests', - 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 IncompleteResult ───────────────────── - -export class IncompleteResultTaskInputResponseIncompleteScenario - implements ClientScenario -{ - name = 'incomplete-result-task-input-response-incomplete'; - specVersions: SpecVersion[] = ['draft']; - description = `Test that tasks/input_response can itself return an IncompleteResult (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. - -**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\` -4. Client sends another \`tasks/input_response\` with the additional responses -5. Task completes - -This tests the schema: \`TaskInputResponseResultResponse.result: Result | IncompleteResult\``; - - 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_incomplete_result_task_multi_input', - arguments: {}, - task: { ttl: 30000 } - }); - - const task = r1.result?.task as { taskId?: string } | undefined; - if (!task?.taskId) { - checks.push({ - id: 'incomplete-result-task-multi-input-prereq', - name: 'IncompleteResultTaskMultiInputPrereq', - 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 || - !isIncompleteResult(r3.result) || - !r3.result.inputRequests - ) { - checks.push({ - id: 'incomplete-result-task-multi-input-prereq', - name: 'IncompleteResultTaskMultiInputPrereq', - 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 IncompleteResult 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 (!isIncompleteResult(r4Result)) { - r4Errors.push( - 'Expected IncompleteResult from tasks/input_response (additional input needed)' - ); - } else if (!r4Result.inputRequests) { - r4Errors.push( - 'IncompleteResult from tasks/input_response missing inputRequests' - ); - } - - checks.push({ - id: 'incomplete-result-task-input-response-returns-incomplete', - name: 'IncompleteResultTaskInputResponseReturnsIncomplete', - description: - 'tasks/input_response returns IncompleteResult 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 && isIncompleteResult(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: 'incomplete-result-task-multi-input-completed', - name: 'IncompleteResultTaskMultiInputCompleted', - 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: 'incomplete-result-task-input-response-returns-incomplete', - name: 'IncompleteResultTaskInputResponseReturnsIncomplete', - description: - 'tasks/input_response returns IncompleteResult 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-helpers.ts b/src/scenarios/server/input-required-result-helpers.ts new file mode 100644 index 00000000..18bff399 --- /dev/null +++ b/src/scenarios/server/input-required-result-helpers.ts @@ -0,0 +1,165 @@ +/** + * Helpers for SEP-2322 conformance tests. + * + * Provides InputRequiredResult-specific type guards, mock response builders, + * and a stateless JSON-RPC transport helper. + */ + +import { DRAFT_PROTOCOL_VERSION } from '../../types'; + +// ─── JSON-RPC Types ────────────────────────────────────────────────────────── + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: Record; + error?: { code: number; message: string; data?: unknown }; +} + +// ─── Stateless RPC Helper ──────────────────────────────────────────────────── + +let nextId = 1; + +/** + * Send a stateless JSON-RPC request (SEP-2575 pattern). + * Automatically injects _meta with protocolVersion, clientInfo, clientCapabilities. + */ +export async function sendRpc( + serverUrl: string, + method: string, + params?: Record +): Promise { + const id = nextId++; + + const enrichedParams = { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { + name: 'conformance-test-client', + version: '1.0.0' + }, + 'io.modelcontextprotocol/clientCapabilities': { + sampling: {}, + elicitation: {} + }, + ...(params?._meta as Record | undefined) + } + }; + + const response = await fetch(serverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params: enrichedParams }) + }); + + return (await response.json()) as JsonRpcResponse; +} + +// ─── InputRequiredResult Types ─────────────────────────────────────────────── + +export interface InputRequiredResultData { + resultType?: 'input_required'; + inputRequests?: Record; + requestState?: string; + _meta?: Record; + [key: string]: unknown; +} + +export interface InputRequestObject { + method: string; + params?: Record; +} + +// ─── Type Guards ───────────────────────────────────────────────────────────── + +/** + * Check if a JSON-RPC result is an InputRequiredResult. + */ +export function isInputRequiredResult( + result: Record | undefined +): result is InputRequiredResultData { + if (!result) return false; + if (result.resultType === 'input_required') return true; + return false; +} + +/** + * Check if a JSON-RPC result is a complete result (not input_required). + * complete is the default so if resultType is missing we assume it's complete. + */ +export function isCompleteResult( + result: Record | undefined +): boolean { + if (!result) return false; + if (result.resultType === 'input_required') return false; + return true; +} + +/** + * Extract inputRequests from an InputRequiredResult. + */ +export function getInputRequests( + result: InputRequiredResultData +): Record | undefined { + return result.inputRequests; +} + +// ─── Mock Response Builders ────────────────────────────────────────────────── + +/** + * Build a mock elicitation response (ElicitResult). + */ +export function mockElicitResponse( + content: Record +): Record { + return { + action: 'accept', + content + }; +} + +/** + * Build a mock sampling response (CreateMessageResult). + */ +export function mockSamplingResponse(text: string): Record { + return { + role: 'assistant', + content: { + type: 'text', + text + }, + model: 'test-model', + stopReason: 'endTurn' + }; +} + +/** + * Build a mock list roots response (ListRootsResult). + */ +export function mockListRootsResponse(): Record { + return { + roots: [ + { + uri: 'file:///test/root', + name: 'Test Root' + } + ] + }; +} + +// ─── Spec References ───────────────────────────────────────────────────────── + +/** + * SEP reference for InputRequiredResult / MRTR tests. + */ +export const MRTR_SPEC_REFERENCES = [ + { + id: 'SEP-2322', + url: 'https://github.com/modelcontextprotocol/specification/pull/2322' + } +]; diff --git a/src/scenarios/server/incomplete-result.ts b/src/scenarios/server/input-required-result.ts similarity index 67% rename from src/scenarios/server/incomplete-result.ts rename to src/scenarios/server/input-required-result.ts index 7b4b63c1..9bb2b4b4 100644 --- a/src/scenarios/server/incomplete-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -1,40 +1,44 @@ /** - * 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, + ClientScenario, + ConformanceCheck, + DRAFT_PROTOCOL_VERSION, + SpecVersion +} from '../../types'; +import { + sendRpc, + isInputRequiredResult, isCompleteResult, mockElicitResponse, mockSamplingResponse, mockListRootsResponse, MRTR_SPEC_REFERENCES -} from './incomplete-result-helpers'; +} from './input-required-result-helpers'; // ─── A1: Basic Elicitation ──────────────────────────────────────────────────── -export class IncompleteResultBasicElicitationScenario - implements ClientScenario -{ - name = 'incomplete-result-basic-elicitation'; - specVersions: SpecVersion[] = ['draft']; - description = `Test basic ephemeral IncompleteResult flow with a single elicitation input request (SEP-2322). +export class InputRequiredResultBasicElicitationScenario implements ClientScenario { + name = 'input-required-result-basic-elicitation'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single elicitation input request (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_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", @@ -65,11 +69,9 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - - // Round 1: Initial call — expect IncompleteResult - const r1 = await session.send('tools/call', { - name: 'test_tool_with_elicitation', + // Round 1: Initial call — expect InputRequiredResult + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', arguments: {} }); @@ -80,14 +82,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 +103,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 +115,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)) { - const r2 = await session.send('tools/call', { - name: 'test_tool_with_elicitation', + if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', arguments: {}, inputResponses: { user_name: mockElicitResponse({ name: 'Alice' }) @@ -146,8 +148,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 +161,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 +178,21 @@ Implement a tool named \`test_tool_with_elicitation\` (no arguments required). // ─── A2: Basic Sampling ────────────────────────────────────────────────────── -export class IncompleteResultBasicSamplingScenario implements ClientScenario { - name = 'incomplete-result-basic-sampling'; - specVersions: SpecVersion[] = ['draft']; - description = `Test basic ephemeral IncompleteResult flow with a single sampling input request (SEP-2322). +export class InputRequiredResultBasicSamplingScenario implements ClientScenario { + name = 'input-required-result-basic-sampling'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single sampling input request (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_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", @@ -211,11 +214,9 @@ Implement a tool named \`test_incomplete_result_sampling\` (no arguments require const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Initial call - const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_sampling', + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_sampling', arguments: {} }); @@ -226,11 +227,13 @@ 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 +250,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 +262,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', + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_sampling', arguments: {}, inputResponses: { [inputKey]: mockSamplingResponse('The capital of France is Paris.') @@ -286,8 +289,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 +302,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 +319,21 @@ 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'; - specVersions: SpecVersion[] = ['draft']; - description = `Test basic ephemeral IncompleteResult flow with a single roots/list input request (SEP-2322). +export class InputRequiredResultBasicListRootsScenario implements ClientScenario { + name = 'input-required-result-basic-list-roots'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test basic ephemeral InputRequiredResult flow with a single roots/list input request (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_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", @@ -345,11 +349,9 @@ Implement a tool named \`test_incomplete_result_list_roots\` (no arguments requi const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Initial call - const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_list_roots', + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_list_roots', arguments: {} }); @@ -360,11 +362,13 @@ 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 +385,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 +397,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', + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_list_roots', arguments: {}, inputResponses: { [inputKey]: mockListRootsResponse() @@ -420,8 +424,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 +437,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 +454,21 @@ 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'; - specVersions: SpecVersion[] = ['draft']; - description = `Test that requestState is correctly round-tripped in ephemeral IncompleteResult flow (SEP-2322). +export class InputRequiredResultRequestStateScenario implements ClientScenario { + name = 'input-required-result-request-state'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test that requestState is correctly round-tripped in ephemeral InputRequiredResult flow (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_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", @@ -487,11 +492,9 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_request_state', + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', arguments: {} }); @@ -500,25 +503,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 +530,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', + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', arguments: {}, inputResponses: { [inputKey]: mockElicitResponse({ ok: true }) @@ -563,8 +566,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 +579,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 +596,21 @@ Implement a tool named \`test_incomplete_result_request_state\` (no arguments re // ─── A5: Multiple Input Requests ───────────────────────────────────────────── -export class IncompleteResultMultipleInputRequestsScenario - implements ClientScenario -{ - name = 'incomplete-result-multiple-input-requests'; - specVersions: SpecVersion[] = ['draft']; - description = `Test multiple input requests in a single IncompleteResult (SEP-2322). +export class InputRequiredResultMultipleInputRequestsScenario implements ClientScenario { + name = 'input-required-result-multiple-input-requests'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test multiple input requests in a single InputRequiredResult (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_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", @@ -643,11 +645,9 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_multiple_inputs', + const r1 = await sendRpc(serverUrl, 'tools/call', { + 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') { @@ -717,8 +717,8 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments } } - const r2 = await session.send('tools/call', { - name: 'test_incomplete_result_multiple_inputs', + const r2 = await sendRpc(serverUrl, 'tools/call', { + 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,21 @@ Implement a tool named \`test_incomplete_result_multiple_inputs\` (no arguments // ─── A6: Multi-Round ───────────────────────────────────────────────────────── -export class IncompleteResultMultiRoundScenario implements ClientScenario { - name = 'incomplete-result-multi-round'; - specVersions: SpecVersion[] = ['draft']; - description = `Test multi-round ephemeral IncompleteResult flow with evolving requestState (SEP-2322). +export class InputRequiredResultMultiRoundScenario implements ClientScenario { + name = 'input-required-result-multi-round'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test multi-round ephemeral InputRequiredResult flow with evolving requestState (SEP-2322). **Server Implementation Requirements:** -Implement a tool named \`test_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 +802,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", @@ -829,11 +830,9 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_multi_round', + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', arguments: {} }); @@ -843,7 +842,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 +850,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', + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', arguments: {}, inputResponses: { [r1InputKey]: mockElicitResponse({ name: 'Alice' }) @@ -883,7 +882,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 +893,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', + const r3 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_multi_round', arguments: {}, inputResponses: { [r2InputKey]: mockElicitResponse({ color: 'blue' }) @@ -925,8 +924,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 +937,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,28 +954,25 @@ Implement a tool named \`test_incomplete_result_multi_round\` (no arguments requ // ─── A7: Missing Input Response ────────────────────────────────────────────── -export class IncompleteResultMissingInputResponseScenario - implements ClientScenario -{ - name = 'incomplete-result-missing-input-response'; - specVersions: SpecVersion[] = ['draft']; +export class InputRequiredResultMissingInputResponseScenario implements ClientScenario { + name = 'input-required-result-missing-input-response'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; description = `Test error handling when client sends wrong/missing inputResponses (SEP-2322). **Server Implementation Requirements:** -Use the same tool as A1: \`test_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[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1: Send wrong inputResponses (wrong key) - const r1 = await session.send('tools/call', { - name: 'test_incomplete_result_elicitation', + const r1 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_elicitation', arguments: {}, inputResponses: { wrong_key: mockElicitResponse({ data: 'wrong' }) @@ -989,23 +985,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 +1010,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 +1027,21 @@ 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'; - specVersions: SpecVersion[] = ['draft']; - description = `Test IncompleteResult on a non-tool request (prompts/get) to verify IncompleteResult is universal (SEP-2322). +export class InputRequiredResultNonToolRequestScenario implements ClientScenario { + name = 'input-required-result-non-tool-request'; + readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const; + specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION]; + description = `Test InputRequiredResult on a non-tool request (prompts/get) to verify InputRequiredResult is universal (SEP-2322). **Server Implementation Requirements:** -Implement a prompt named \`test_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", @@ -1067,11 +1064,9 @@ Implement a prompt named \`test_incomplete_result_prompt\` that requires elicita const checks: ConformanceCheck[] = []; try { - const session = await createRawSession(serverUrl); - // Round 1 - const r1 = await session.send('prompts/get', { - name: 'test_incomplete_result_prompt' + const r1 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt' }); const r1Result = r1.result; @@ -1079,16 +1074,17 @@ 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 +1093,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', + const r2 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt', inputResponses: { [inputKey]: mockElicitResponse({ context: 'test context' }) }, @@ -1125,8 +1121,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 +1134,10 @@ 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/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 }); }); diff --git a/src/scenarios/server/mrtr.test.ts b/src/scenarios/server/mrtr.test.ts new file mode 100644 index 00000000..d67750ea --- /dev/null +++ b/src/scenarios/server/mrtr.test.ts @@ -0,0 +1,115 @@ +/** + * 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'; + +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() + ]; + + 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..ec5ffc37 --- /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' + + - check: mrtr-client-request-state-echoed + text: 'client MUST echo back the exact value of requestState when retrying' + + - check: mrtr-client-request-state-echoed + text: 'Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents' + + - 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' + + - check: mrtr-client-jsonrpc-id-different + text: 'The JSON-RPC id MUST be different between the initial request and the retry' + + - 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) ──────────────────────────────────────────── + + # 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'