From c3d520c5389646a5c0fe64a7649086e0be14abc5 Mon Sep 17 00:00:00 2001 From: Yudistira Putra <85178972+Yudis-bit@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:58:05 +0700 Subject: [PATCH 1/2] fix(scenarios): guard empty inputRequests in input-required-result scenarios When a server returns an InputRequiredResult with an empty or missing inputRequests object, `Object.keys(result.inputRequests!)[0]` evaluates to undefined. This previously caused the test harness to send `inputResponses: { "undefined": ... }` over the wire in subsequent rounds instead of recognizing that the round-2/round-3 retry is untestable. Per the untestable convention (#248, #372), mark the completion check as untestable when round 1 returns no inputRequests, and safely guard all 5 inputRequests extraction sites in input-required-result.ts. Closes #440 --- .../typescript/sep-2322-mrtr-broken-server.ts | 16 ++ src/scenarios/server/input-required-result.ts | 202 ++++++++++++------ src/scenarios/server/negative-mrtr.test.ts | 22 +- 3 files changed, 168 insertions(+), 72 deletions(-) diff --git a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts index 72cf6021..c069ab3c 100644 --- a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -64,6 +64,12 @@ handlers['tools/list'] = () => ({ name: 'test_input_required_result_capabilities', description: 'Test tool for client capability handling', inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_request_state', + description: + 'Test tool returning input_required with requestState but no input requests', + inputSchema: { type: 'object' as const, properties: {} } } ] }); @@ -114,6 +120,16 @@ handlers['tools/call'] = (params) => { }; } + case 'test_input_required_result_request_state': { + // Answers with conformant input_required result that carries requestState + // but no input requests, so round 2 retry cannot be exercised. + return { + resultType: 'input_required', + requestState: 'no-input-requested', + inputRequests: {} + }; + } + case 'test_input_required_result_tampered_state': { if (inputResponses) { // BUG 3: Accepts ANY requestState without verification diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index 64ad4ba9..c14981be 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -22,7 +22,7 @@ import { mockListRootsResponse, MRTR_SPEC_REFERENCES } from './input-required-result-helpers'; -import { notTestable } from '../untestable'; +import { notTestable, untestableCheck } from '../untestable'; // ─── A1: Basic Elicitation ──────────────────────────────────────────────────── @@ -537,40 +537,52 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument // Round 2: Retry with inputResponses + requestState if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { - const inputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await sendRpc(serverUrl, 'tools/call', { - name: 'test_input_required_result_request_state', - arguments: {}, - inputResponses: { - [inputKey]: mockElicitResponse({ ok: true }) - }, - requestState: r1Result.requestState - }); - - const r2Result = r2.result; - const r2Errors: string[] = []; - - if (r2.error) { - r2Errors.push(`JSON-RPC error: ${r2.error.message}`); - } else if (!r2Result) { - r2Errors.push('No result in response'); - } else if (!isCompleteResult(r2Result)) { - r2Errors.push( - 'Expected complete result after retry with requestState' + const inputKey = Object.keys(r1Result.inputRequests ?? {})[0]; + if (!inputKey) { + checks.push( + untestableCheck( + 'sep-2322-request-state-complete', + 'InputRequiredResultRequestStateComplete', + 'Server validates echoed requestState and returns complete result', + 'server returned no inputRequests, so round-2 retry could not be exercised', + MRTR_SPEC_REFERENCES + ) ); - } + } else { + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', + arguments: {}, + inputResponses: { + [inputKey]: mockElicitResponse({ ok: true }) + }, + requestState: r1Result.requestState + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with requestState' + ); + } - checks.push({ - id: 'sep-2322-request-state-complete', - name: 'InputRequiredResultRequestStateComplete', - description: - 'Server validates echoed requestState and returns complete result', - status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r2Result } - }); + checks.push({ + id: 'sep-2322-request-state-complete', + name: 'InputRequiredResultRequestStateComplete', + description: + 'Server validates echoed requestState and returns complete result', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } } } catch (error) { checks.push({ @@ -863,7 +875,19 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments if (!round1Complete || !isInputRequiredResult(r1Result)) return checks; // Round 2: Retry — expect another InputRequiredResult - const r1InputKey = Object.keys(r1Result.inputRequests!)[0]; + const r1InputKey = Object.keys(r1Result.inputRequests ?? {})[0]; + if (!r1InputKey) { + checks.push( + untestableCheck( + 'sep-2322-multi-round-r2', + 'InputRequiredResultMultiRoundR2', + 'Round 2: Server returns another InputRequiredResult with updated requestState', + 'server returned no inputRequests in round 1, so round-2 retry could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; + } const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multi_round', arguments: {}, @@ -906,7 +930,19 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments if (!round2Complete || !isInputRequiredResult(r2Result)) return checks; // Round 3: Final retry — expect complete result - const r2InputKey = Object.keys(r2Result.inputRequests!)[0]; + const r2InputKey = Object.keys(r2Result.inputRequests ?? {})[0]; + if (!r2InputKey) { + checks.push( + untestableCheck( + 'sep-2322-multi-round-r3', + 'InputRequiredResultMultiRoundR3', + 'Round 3: Server returns complete result', + 'server returned no inputRequests in round 2, so round-3 retry could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; + } const r3 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_multi_round', arguments: {}, @@ -1093,43 +1129,55 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli // Round 2: Retry with inputResponses if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { - const inputKey = Object.keys(r1Result.inputRequests!)[0]; - const r2 = await sendRpc(serverUrl, 'prompts/get', { - name: 'test_input_required_result_prompt', - inputResponses: { - [inputKey]: mockElicitResponse({ context: 'test context' }) - }, - ...(r1Result.requestState !== undefined - ? { requestState: r1Result.requestState } - : {}) - }); - - const r2Result = r2.result; - const r2Errors: string[] = []; - - if (r2.error) { - r2Errors.push(`JSON-RPC error: ${r2.error.message}`); - } else if (!r2Result) { - r2Errors.push('No result in response'); - } else if (!isCompleteResult(r2Result)) { - r2Errors.push('Expected complete GetPromptResult after retry'); - } else if (!r2Result.messages) { - r2Errors.push( - 'Complete result missing messages (expected GetPromptResult)' + const inputKey = Object.keys(r1Result.inputRequests ?? {})[0]; + if (!inputKey) { + checks.push( + untestableCheck( + 'sep-2322-non-tool-complete', + 'InputRequiredResultNonToolComplete', + 'prompts/get returns complete GetPromptResult after retry with inputResponses', + 'server returned no inputRequests, so retry with inputResponses could not be exercised', + MRTR_SPEC_REFERENCES + ) ); - } + } else { + const r2 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt', + inputResponses: { + [inputKey]: mockElicitResponse({ context: 'test context' }) + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); + + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push('Expected complete GetPromptResult after retry'); + } else if (!r2Result.messages) { + r2Errors.push( + 'Complete result missing messages (expected GetPromptResult)' + ); + } - checks.push({ - id: 'sep-2322-non-tool-complete', - name: 'InputRequiredResultNonToolComplete', - description: - 'prompts/get returns complete GetPromptResult after retry with inputResponses', - status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r2Result } - }); + checks.push({ + id: 'sep-2322-non-tool-complete', + name: 'InputRequiredResultNonToolComplete', + description: + 'prompts/get returns complete GetPromptResult after retry with inputResponses', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); + } } } catch (error) { checks.push({ @@ -1335,7 +1383,19 @@ JSON-RPC error (code -32602 or similar) indicating integrity check failure.`; // Round 2: Tamper with the requestState and retry const tamperedState = r1Result.requestState + '-TAMPERED'; - const inputKey = Object.keys(r1Result.inputRequests!)[0]; + const inputKey = Object.keys(r1Result.inputRequests ?? {})[0]; + if (!inputKey) { + checks.push( + untestableCheck( + 'sep-2322-reject-tampered-state', + 'RejectTamperedState', + 'Server rejects tampered requestState with error', + 'server returned no inputRequests, so round-2 retry could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; + } const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_tampered_state', arguments: {}, diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts index 871ad73b..d9f50d01 100644 --- a/src/scenarios/server/negative-mrtr.test.ts +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -14,7 +14,8 @@ import { InputRequiredResultResultTypeScenario, InputRequiredResultUnsupportedMethodsScenario, InputRequiredResultTamperedStateScenario, - InputRequiredResultCapabilityCheckScenario + InputRequiredResultCapabilityCheckScenario, + InputRequiredResultRequestStateScenario } from './input-required-result'; import { formatWireViolation, @@ -157,4 +158,23 @@ describe('SEP-2322 MRTR negative tests', () => { expect(capabilityCheck?.errorMessage).toContain('Not testable:'); expect(capabilityCheck?.details?.untestable).toBe(true); }, 10000); + + it('reports sep-2322-request-state-complete as untestable when server returns no inputRequests in round 1', async () => { + const scenario = new InputRequiredResultRequestStateScenario(); + const checks = await scenario.run(testContext(SERVER_URL)); + + const incompleteCheck = checks.find( + (c) => c.id === 'sep-2322-request-state-incomplete' + ); + expect(incompleteCheck).toBeDefined(); + expect(incompleteCheck?.status).toBe('SUCCESS'); + + const completeCheck = checks.find( + (c) => c.id === 'sep-2322-request-state-complete' + ); + expect(completeCheck).toBeDefined(); + expect(completeCheck?.status).toBe('FAILURE'); + expect(completeCheck?.errorMessage).toContain('Not testable:'); + expect(completeCheck?.details?.untestable).toBe(true); + }, 10000); }); From 75b6ead0336449d9540b98a02e8aad675f5e9f37 Mon Sep 17 00:00:00 2001 From: Yudistira Putra <85178972+Yudis-bit@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:57:09 +0700 Subject: [PATCH 2/2] fix(scenarios): handle missing MRTR input prerequisites consistently --- .../server/input-required-result.test.ts | 283 +++++++++++++++++ src/scenarios/server/input-required-result.ts | 295 ++++++++++-------- 2 files changed, 446 insertions(+), 132 deletions(-) create mode 100644 src/scenarios/server/input-required-result.test.ts diff --git a/src/scenarios/server/input-required-result.test.ts b/src/scenarios/server/input-required-result.test.ts new file mode 100644 index 00000000..d3ff3ee5 --- /dev/null +++ b/src/scenarios/server/input-required-result.test.ts @@ -0,0 +1,283 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { testContext } from '../../connection/testing'; +import { DRAFT_PROTOCOL_VERSION } from '../../types'; +import { + InputRequiredResultBasicElicitationScenario, + InputRequiredResultBasicSamplingScenario, + InputRequiredResultBasicListRootsScenario, + InputRequiredResultRequestStateScenario, + InputRequiredResultMultipleInputRequestsScenario, + InputRequiredResultMultiRoundScenario, + InputRequiredResultNonToolRequestScenario, + InputRequiredResultTamperedStateScenario +} from './input-required-result'; +import type { InputRequestObject } from './input-required-result-helpers'; + +const elicitation: InputRequestObject = { + method: 'elicitation/create', + params: { + message: 'Confirm?', + requestedSchema: { type: 'object', properties: {} } + } +}; +const sampling: InputRequestObject = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 10 + } +}; +const roots: InputRequestObject = { method: 'roots/list', params: {} }; + +const retryCases = [ + { + Scenario: InputRequiredResultRequestStateScenario, + check: 'sep-2322-request-state-complete', + input: elicitation + }, + { + Scenario: InputRequiredResultMultiRoundScenario, + check: 'sep-2322-multi-round-r2', + input: elicitation + }, + { + Scenario: InputRequiredResultNonToolRequestScenario, + check: 'sep-2322-non-tool-complete', + input: elicitation + }, + { + Scenario: InputRequiredResultTamperedStateScenario, + check: 'sep-2322-reject-tampered-state', + input: elicitation + } +]; +const fixtureCases = [ + { + Scenario: InputRequiredResultBasicElicitationScenario, + check: 'sep-2322-elicitation-incomplete', + input: elicitation + }, + { + Scenario: InputRequiredResultBasicSamplingScenario, + check: 'sep-2322-sampling-incomplete', + input: sampling + }, + { + Scenario: InputRequiredResultBasicListRootsScenario, + check: 'sep-2322-list-roots-incomplete', + input: roots + }, + { + Scenario: InputRequiredResultMultipleInputRequestsScenario, + check: 'sep-2322-multiple-inputs-incomplete', + input: elicitation + } +]; + +describe('InputRequiredResult input request prerequisites', () => { + let server: Server; + let serverUrl: string; + let replies: Array>; + let requests: Array<{ + method: string; + params: { inputResponses?: Record; requestState?: string }; + }>; + + beforeAll(async () => { + server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) body += chunk; + const request = JSON.parse(body); + requests.push(request); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + ...(replies.shift() ?? { + error: { code: -32603, message: 'Unexpected retry' } + }) + }) + ); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + serverUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/mcp`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + }); + + beforeEach(() => { + replies = []; + requests = []; + }); + + describe.each([ + { label: 'empty', fields: { inputRequests: {} } }, + { label: 'omitted', fields: {} } + ])('$label inputRequests', ({ fields }) => { + it.each([...retryCases, ...fixtureCases])( + 'reports $check as untestable without sending a retry', + async ({ Scenario, check }) => { + replies.push({ + result: { + resultType: 'input_required', + requestState: 'state-1', + ...fields + } + }); + + const checks = await new Scenario().run( + testContext(serverUrl, DRAFT_PROTOCOL_VERSION) + ); + + expect(checks.filter((c) => c.id === check)).toEqual([ + expect.objectContaining({ + status: 'FAILURE', + errorMessage: expect.stringMatching( + /^Not testable:.*inputRequests/ + ), + details: expect.objectContaining({ untestable: true }) + }) + ]); + expect( + checks + .filter((c) => c.id !== check) + .every((c) => c.status === 'SUCCESS') + ).toBe(true); + expect(requests).toHaveLength(1); + expect(requests[0].params.inputResponses).toBeUndefined(); + } + ); + + it('stops before round 3 when round 2 names no input requests', async () => { + replies.push( + { + result: { + resultType: 'input_required', + requestState: 'state-1', + inputRequests: { step1: elicitation } + } + }, + { + result: { + resultType: 'input_required', + requestState: 'state-2', + ...fields + } + } + ); + + const checks = await new InputRequiredResultMultiRoundScenario().run( + testContext(serverUrl, DRAFT_PROTOCOL_VERSION) + ); + + expect(checks.map((c) => [c.id, c.status])).toEqual([ + ['sep-2322-multi-round-r1', 'SUCCESS'], + ['sep-2322-multi-round-r2', 'SUCCESS'], + ['sep-2322-multi-round-r3', 'FAILURE'] + ]); + expect(checks[2].details?.untestable).toBe(true); + expect(checks[2].errorMessage).toMatch(/^Not testable:.*round 2/); + expect(requests).toHaveLength(2); + expect(Object.keys(requests[1].params.inputResponses ?? {})).toEqual([ + 'step1' + ]); + expect(requests[1].params.requestState).toBe('state-1'); + }); + }); + + describe.each(['', 'undefined'])('server-assigned key %j', (key) => { + it.each([...retryCases, ...fixtureCases.slice(1, 3)])( + 'echoes the key when exercising $check', + async ({ Scenario, input }) => { + replies.push({ + result: { + resultType: 'input_required', + requestState: 'state-1', + inputRequests: { [key]: input } + } + }); + if (Scenario === InputRequiredResultMultiRoundScenario) { + replies.push({ + result: { + resultType: 'input_required', + requestState: 'state-2', + inputRequests: { [key]: elicitation } + } + }); + } + replies.push( + Scenario === InputRequiredResultTamperedStateScenario + ? { error: { code: -32602, message: 'Invalid requestState' } } + : { + result: { + resultType: 'complete', + ...(Scenario === InputRequiredResultNonToolRequestScenario + ? { + messages: [ + { + role: 'user', + content: { type: 'text', text: 'Done' } + } + ] + } + : { content: [{ type: 'text', text: 'state-ok' }] }) + } + } + ); + + const checks = await new Scenario().run( + testContext(serverUrl, DRAFT_PROTOCOL_VERSION) + ); + + expect(checks.every((c) => c.status === 'SUCCESS')).toBe(true); + expect(requests).toHaveLength( + Scenario === InputRequiredResultMultiRoundScenario ? 3 : 2 + ); + for (const request of requests.slice(1)) { + expect(Object.keys(request.params.inputResponses ?? {})).toEqual([ + key + ]); + } + expect(requests[1].params.requestState).toBe( + Scenario === InputRequiredResultTamperedStateScenario + ? 'state-1-TAMPERED' + : 'state-1' + ); + if (requests.length === 3) + expect(requests[2].params.requestState).toBe('state-2'); + } + ); + }); + + it.each(fixtureCases.slice(1, 3))( + 'keeps a wrong request method as an ordinary failure for $check', + async ({ Scenario, check }) => { + replies.push({ + result: { + resultType: 'input_required', + requestState: 'state-1', + inputRequests: { confirm: elicitation } + } + }); + + const checks = await new Scenario().run( + testContext(serverUrl, DRAFT_PROTOCOL_VERSION) + ); + + expect(checks).toHaveLength(1); + expect(checks[0]).toMatchObject({ id: check, status: 'FAILURE' }); + expect(checks[0].errorMessage).toContain('Expected method'); + expect(checks[0].details?.untestable).toBeUndefined(); + expect(requests).toHaveLength(1); + } + ); +}); diff --git a/src/scenarios/server/input-required-result.ts b/src/scenarios/server/input-required-result.ts index c14981be..15f6f309 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -91,9 +91,18 @@ Implement a tool named \`test_input_required_result_elicitation\` (no arguments 'Server should return resultType: "input_required" with inputRequests.' ); } else { - if (!r1Result.inputRequests) { - r1Errors.push('InputRequiredResult missing inputRequests'); - } else if (!r1Result.inputRequests['user_name']) { + if (Object.keys(r1Result.inputRequests ?? {}).length === 0) { + checks.push( + untestableCheck( + 'sep-2322-elicitation-incomplete', + 'InputRequiredResultElicitationIncomplete', + 'Server returns InputRequiredResult with elicitation inputRequest', + 'server returned no inputRequests, so the elicitation fixture could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; + } else if (!r1Result.inputRequests?.['user_name']) { r1Errors.push('inputRequests missing expected key "user_name"'); } else { const req = r1Result.inputRequests['user_name']; @@ -226,6 +235,10 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req const r1Result = r1.result; const r1Errors: string[] = []; + const inputRequests = isInputRequiredResult(r1Result) + ? (r1Result.inputRequests ?? {}) + : {}; + const inputKey = Object.keys(inputRequests)[0]; if (r1.error) { r1Errors.push(`JSON-RPC error: ${r1.error.message}`); @@ -235,21 +248,23 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req r1Errors.push( 'Expected InputRequiredResult with sampling inputRequest' ); + } else if (inputKey === undefined) { + checks.push( + untestableCheck( + 'sep-2322-sampling-incomplete', + 'InputRequiredResultSamplingIncomplete', + 'Server returns InputRequiredResult with sampling inputRequest', + 'server returned no inputRequests, so the sampling fixture could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; } else { - if (!r1Result.inputRequests) { - r1Errors.push('InputRequiredResult missing inputRequests'); - } else { - const key = Object.keys(r1Result.inputRequests)[0]; - if (!key) { - r1Errors.push('inputRequests map is empty'); - } else { - const req = r1Result.inputRequests[key]; - if (req.method !== 'sampling/createMessage') { - r1Errors.push( - `Expected method "sampling/createMessage", got "${req.method}"` - ); - } - } + const req = inputRequests[inputKey]; + if (req.method !== 'sampling/createMessage') { + r1Errors.push( + `Expected method "sampling/createMessage", got "${req.method}"` + ); } } @@ -266,8 +281,11 @@ Implement a tool named \`test_input_required_result_sampling\` (no arguments req }); // Round 2: Retry with inputResponses - if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { - const inputKey = Object.keys(r1Result.inputRequests!)[0]; + if ( + r1Errors.length === 0 && + isInputRequiredResult(r1Result) && + inputKey !== undefined + ) { const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_sampling', arguments: {}, @@ -362,6 +380,10 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r const r1Result = r1.result; const r1Errors: string[] = []; + const inputRequests = isInputRequiredResult(r1Result) + ? (r1Result.inputRequests ?? {}) + : {}; + const inputKey = Object.keys(inputRequests)[0]; if (r1.error) { r1Errors.push(`JSON-RPC error: ${r1.error.message}`); @@ -371,21 +393,21 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r r1Errors.push( 'Expected InputRequiredResult with roots/list inputRequest' ); + } else if (inputKey === undefined) { + checks.push( + untestableCheck( + 'sep-2322-list-roots-incomplete', + 'InputRequiredResultListRootsIncomplete', + 'Server returns InputRequiredResult with roots/list inputRequest', + 'server returned no inputRequests, so the roots/list fixture could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; } else { - if (!r1Result.inputRequests) { - r1Errors.push('InputRequiredResult missing inputRequests'); - } else { - const key = Object.keys(r1Result.inputRequests)[0]; - if (!key) { - r1Errors.push('inputRequests map is empty'); - } else { - const req = r1Result.inputRequests[key]; - if (req.method !== 'roots/list') { - r1Errors.push( - `Expected method "roots/list", got "${req.method}"` - ); - } - } + const req = inputRequests[inputKey]; + if (req.method !== 'roots/list') { + r1Errors.push(`Expected method "roots/list", got "${req.method}"`); } } @@ -402,8 +424,11 @@ Implement a tool named \`test_input_required_result_list_roots\` (no arguments r }); // Round 2: Retry with inputResponses - if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { - const inputKey = Object.keys(r1Result.inputRequests!)[0]; + if ( + r1Errors.length === 0 && + isInputRequiredResult(r1Result) && + inputKey !== undefined + ) { const r2 = await sendRpc(serverUrl, 'tools/call', { name: 'test_input_required_result_list_roots', arguments: {}, @@ -518,16 +543,12 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument if (typeof r1Result.requestState !== 'string') { r1Errors.push('requestState must be a string'); } - if (!r1Result.inputRequests) { - r1Errors.push('InputRequiredResult missing inputRequests'); - } } checks.push({ id: 'sep-2322-request-state-incomplete', name: 'InputRequiredResultRequestStateIncomplete', - description: - 'Server returns InputRequiredResult with both inputRequests and requestState', + description: 'Server returns InputRequiredResult with requestState', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, @@ -538,7 +559,7 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument // Round 2: Retry with inputResponses + requestState if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests ?? {})[0]; - if (!inputKey) { + if (inputKey === undefined) { checks.push( untestableCheck( 'sep-2322-request-state-complete', @@ -548,48 +569,47 @@ Implement a tool named \`test_input_required_result_request_state\` (no argument MRTR_SPEC_REFERENCES ) ); - } else { - const r2 = await sendRpc(serverUrl, 'tools/call', { - name: 'test_input_required_result_request_state', - arguments: {}, - inputResponses: { - [inputKey]: mockElicitResponse({ ok: true }) - }, - requestState: r1Result.requestState - }); - - const r2Result = r2.result; - const r2Errors: string[] = []; - - if (r2.error) { - r2Errors.push(`JSON-RPC error: ${r2.error.message}`); - } else if (!r2Result) { - r2Errors.push('No result in response'); - } else if (!isCompleteResult(r2Result)) { - r2Errors.push( - 'Expected complete result after retry with requestState' - ); - } + return checks; + } + const r2 = await sendRpc(serverUrl, 'tools/call', { + name: 'test_input_required_result_request_state', + arguments: {}, + inputResponses: { + [inputKey]: mockElicitResponse({ ok: true }) + }, + requestState: r1Result.requestState + }); - checks.push({ - id: 'sep-2322-request-state-complete', - name: 'InputRequiredResultRequestStateComplete', - description: - 'Server validates echoed requestState and returns complete result', - status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r2Result } - }); + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push( + 'Expected complete result after retry with requestState' + ); } + + checks.push({ + id: 'sep-2322-request-state-complete', + name: 'InputRequiredResultRequestStateComplete', + description: + 'Server validates echoed requestState and returns complete result', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); } } catch (error) { checks.push({ id: 'sep-2322-request-state-incomplete', name: 'InputRequiredResultRequestStateIncomplete', - description: - 'Server returns InputRequiredResult with both inputRequests and requestState', + description: 'Server returns InputRequiredResult with requestState', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -661,19 +681,31 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume const r1Result = r1.result; const r1Errors: string[] = []; + const inputRequests = isInputRequiredResult(r1Result) + ? (r1Result.inputRequests ?? {}) + : {}; + const keys = Object.keys(inputRequests); if (r1.error) { r1Errors.push(`JSON-RPC error: ${r1.error.message}`); } else if (!r1Result || !isInputRequiredResult(r1Result)) { r1Errors.push('Expected InputRequiredResult'); - } else if (!r1Result.inputRequests) { - r1Errors.push('InputRequiredResult missing inputRequests'); + } else if (keys.length === 0) { + checks.push( + untestableCheck( + 'sep-2322-multiple-inputs-incomplete', + 'InputRequiredResultMultipleInputsIncomplete', + 'Server returns InputRequiredResult with multiple inputRequests of different types', + 'server returned no inputRequests, so the multiple-input fixture could not be exercised', + MRTR_SPEC_REFERENCES + ) + ); + return checks; } else { if (!r1Result.requestState) { r1Errors.push('InputRequiredResult missing requestState'); } - const keys = Object.keys(r1Result.inputRequests); if (keys.length < 3) { r1Errors.push( `Expected at least 3 inputRequests, got ${keys.length}` @@ -681,9 +713,7 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume } // Check that required method types are present - const methods = new Set( - keys.map((k) => r1Result.inputRequests![k].method) - ); + const methods = new Set(keys.map((k) => inputRequests[k].method)); if (!methods.has('elicitation/create')) { r1Errors.push('Expected an elicitation/create inputRequest'); } @@ -715,7 +745,7 @@ Implement a tool named \`test_input_required_result_multiple_inputs\` (no argume // Round 2: Respond to all input requests if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputResponses: Record = {}; - for (const [key, req] of Object.entries(r1Result.inputRequests!)) { + for (const [key, req] of Object.entries(inputRequests)) { if (req.method === 'elicitation/create') { inputResponses[key] = mockElicitResponse({ name: 'Alice' }); } else if (req.method === 'sampling/createMessage') { @@ -852,7 +882,6 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments !r1.error && r1Result && isInputRequiredResult(r1Result) && - r1Result.inputRequests && r1Result.requestState ) { round1Complete = true; @@ -867,7 +896,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments timestamp: new Date().toISOString(), errorMessage: round1Complete ? undefined - : 'Expected InputRequiredResult with inputRequests and requestState', + : 'Expected InputRequiredResult with requestState', specReferences: MRTR_SPEC_REFERENCES, details: { result: r1Result } }); @@ -876,7 +905,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments // Round 2: Retry — expect another InputRequiredResult const r1InputKey = Object.keys(r1Result.inputRequests ?? {})[0]; - if (!r1InputKey) { + if (r1InputKey === undefined) { checks.push( untestableCheck( 'sep-2322-multi-round-r2', @@ -904,7 +933,6 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments !r2.error && r2Result && isInputRequiredResult(r2Result) && - r2Result.inputRequests && r2Result.requestState ) { // requestState should have changed @@ -931,7 +959,7 @@ Implement a tool named \`test_input_required_result_multi_round\` (no arguments // Round 3: Final retry — expect complete result const r2InputKey = Object.keys(r2Result.inputRequests ?? {})[0]; - if (!r2InputKey) { + if (r2InputKey === undefined) { checks.push( untestableCheck( 'sep-2322-multi-round-r3', @@ -1111,15 +1139,19 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli r1Errors.push(`JSON-RPC error: ${r1.error.message}`); } else if (!r1Result || !isInputRequiredResult(r1Result)) { r1Errors.push('Expected InputRequiredResult from prompts/get'); - } else if (!r1Result.inputRequests) { - r1Errors.push('InputRequiredResult missing inputRequests'); + } else if ( + r1Result.inputRequests === undefined && + r1Result.requestState === undefined + ) { + r1Errors.push( + 'InputRequiredResult missing both inputRequests and requestState' + ); } checks.push({ id: 'sep-2322-non-tool-incomplete', name: 'InputRequiredResultNonToolIncomplete', - description: - 'prompts/get returns InputRequiredResult with inputRequests', + description: 'prompts/get returns InputRequiredResult', status: r1Errors.length === 0 ? 'SUCCESS' : 'FAILURE', timestamp: new Date().toISOString(), errorMessage: r1Errors.length > 0 ? r1Errors.join('; ') : undefined, @@ -1130,7 +1162,7 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli // Round 2: Retry with inputResponses if (r1Errors.length === 0 && isInputRequiredResult(r1Result)) { const inputKey = Object.keys(r1Result.inputRequests ?? {})[0]; - if (!inputKey) { + if (inputKey === undefined) { checks.push( untestableCheck( 'sep-2322-non-tool-complete', @@ -1140,51 +1172,50 @@ Implement a prompt named \`test_input_required_result_prompt\` that requires eli MRTR_SPEC_REFERENCES ) ); - } else { - const r2 = await sendRpc(serverUrl, 'prompts/get', { - name: 'test_input_required_result_prompt', - inputResponses: { - [inputKey]: mockElicitResponse({ context: 'test context' }) - }, - ...(r1Result.requestState !== undefined - ? { requestState: r1Result.requestState } - : {}) - }); - - const r2Result = r2.result; - const r2Errors: string[] = []; - - if (r2.error) { - r2Errors.push(`JSON-RPC error: ${r2.error.message}`); - } else if (!r2Result) { - r2Errors.push('No result in response'); - } else if (!isCompleteResult(r2Result)) { - r2Errors.push('Expected complete GetPromptResult after retry'); - } else if (!r2Result.messages) { - r2Errors.push( - 'Complete result missing messages (expected GetPromptResult)' - ); - } + return checks; + } + const r2 = await sendRpc(serverUrl, 'prompts/get', { + name: 'test_input_required_result_prompt', + inputResponses: { + [inputKey]: mockElicitResponse({ context: 'test context' }) + }, + ...(r1Result.requestState !== undefined + ? { requestState: r1Result.requestState } + : {}) + }); - checks.push({ - id: 'sep-2322-non-tool-complete', - name: 'InputRequiredResultNonToolComplete', - description: - 'prompts/get returns complete GetPromptResult after retry with inputResponses', - status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', - timestamp: new Date().toISOString(), - errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, - specReferences: MRTR_SPEC_REFERENCES, - details: { result: r2Result } - }); + const r2Result = r2.result; + const r2Errors: string[] = []; + + if (r2.error) { + r2Errors.push(`JSON-RPC error: ${r2.error.message}`); + } else if (!r2Result) { + r2Errors.push('No result in response'); + } else if (!isCompleteResult(r2Result)) { + r2Errors.push('Expected complete GetPromptResult after retry'); + } else if (!r2Result.messages) { + r2Errors.push( + 'Complete result missing messages (expected GetPromptResult)' + ); } + + checks.push({ + id: 'sep-2322-non-tool-complete', + name: 'InputRequiredResultNonToolComplete', + description: + 'prompts/get returns complete GetPromptResult after retry with inputResponses', + status: r2Errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: r2Errors.length > 0 ? r2Errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { result: r2Result } + }); } } catch (error) { checks.push({ id: 'sep-2322-non-tool-incomplete', name: 'InputRequiredResultNonToolIncomplete', - description: - 'prompts/get returns InputRequiredResult with inputRequests', + description: 'prompts/get returns InputRequiredResult', status: 'FAILURE', timestamp: new Date().toISOString(), errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`, @@ -1384,7 +1415,7 @@ JSON-RPC error (code -32602 or similar) indicating integrity check failure.`; // Round 2: Tamper with the requestState and retry const tamperedState = r1Result.requestState + '-TAMPERED'; const inputKey = Object.keys(r1Result.inputRequests ?? {})[0]; - if (!inputKey) { + if (inputKey === undefined) { checks.push( untestableCheck( 'sep-2322-reject-tampered-state',