From 0df43f79a0608fa0d91fcd5e2afdbc52b02dc210 Mon Sep 17 00:00:00 2001 From: Dmitry Sergeev Date: Sat, 10 Oct 2026 10:43:15 +0200 Subject: [PATCH] feat(sep-2322): check requestState echo on a retry without inputRequests An InputRequiredResult may carry requestState and no inputRequests: the spec requires at least one of the two. Every MRTR tool in the client scenario sent inputRequests, so a client's requestState-only path was never run. go-sdk returned such results to the caller instead of retrying, and passed the scenario. Add test_mrtr_state_only to the scenario. Its first call returns only a requestState, and the next call is checked as the retry: sep-2322-client-request-state-only-echoed fails when requestState is missing or not byte-identical. Retrying is a MAY, so a client that never retries gets the check as not testable (#248) rather than as a violation. The everything-client calls the new tool, and three inline clients cover the failure cases. --- .../clients/typescript/everything-client.ts | 27 ++++- src/scenarios/client/mrtr-client.test.ts | 75 ++++++++++++ src/scenarios/client/mrtr-client.ts | 110 ++++++++++++++++++ src/seps/sep-2322.yaml | 3 + 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/examples/clients/typescript/everything-client.ts b/examples/clients/typescript/everything-client.ts index b3ccbed3..d068aa96 100644 --- a/examples/clients/typescript/everything-client.ts +++ b/examples/clients/typescript/everything-client.ts @@ -1052,7 +1052,32 @@ async function runMRTRClient(serverUrl: string): Promise { logger.debug('test_mrtr_no_state: MRTR flow completed'); } - // Tool 3: test_mrtr_no_result_type — returns result without resultType field + // Tool 3: test_mrtr_state_only — call, get InputRequiredResult with requestState + // and no inputRequests; nothing to fulfill, so retry with only the state echoed + const rStateOnly = await sendRpc('tools/call', { + name: 'test_mrtr_state_only', + arguments: {} + }); + + const rStateOnlyResult = rStateOnly.result as + | Record + | undefined; + if (rStateOnlyResult?.resultType === 'input_required') { + const requestState = rStateOnlyResult.requestState as string | undefined; + + const retryParams: Record = { + name: 'test_mrtr_state_only', + arguments: {} + }; + if (requestState !== undefined) { + retryParams.requestState = requestState; + } + + await sendRpc('tools/call', retryParams); + logger.debug('test_mrtr_state_only: MRTR flow completed'); + } + + // Tool 4: test_mrtr_no_result_type — returns result without resultType field // Client must treat it as complete (default) and NOT retry const r3 = await sendRpc('tools/call', { name: 'test_mrtr_no_result_type', diff --git a/src/scenarios/client/mrtr-client.test.ts b/src/scenarios/client/mrtr-client.test.ts index 46268aa2..b583a548 100644 --- a/src/scenarios/client/mrtr-client.test.ts +++ b/src/scenarios/client/mrtr-client.test.ts @@ -12,6 +12,47 @@ import { import { getHandler } from '../../../examples/clients/typescript/everything-client'; import { getScenario } from '../index'; +const STATE_ONLY_CHECK = 'sep-2322-client-request-state-only-echoed'; + +async function callStateOnlyTool( + serverUrl: string, + id: number, + extraParams: Record = {} +): Promise | undefined> { + const resp = await fetch(serverUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'test_mrtr_state_only', arguments: {}, ...extraParams } + }) + }); + const body = (await resp.json()) as { result?: Record }; + return body.result; +} + +// Retries the requestState-only round without echoing the state. +async function stateDroppingClient(serverUrl: string): Promise { + await callStateOnlyTool(serverUrl, 1); + await callStateOnlyTool(serverUrl, 2); +} + +// Re-serializes the state before echoing it. +async function stateRewritingClient(serverUrl: string): Promise { + const result = await callStateOnlyTool(serverUrl, 1); + const state = result?.requestState as string; + await callStateOnlyTool(serverUrl, 2, { + requestState: JSON.stringify(JSON.parse(state), null, 1) + }); +} + +// Treats a result without inputRequests as final and never retries. +async function nonRetryingClient(serverUrl: string): Promise { + await callStateOnlyTool(serverUrl, 1); +} + describe('MRTR client scenario (SEP-2322)', () => { test('everything-client passes sep-2322-client-request-state scenario', async () => { const clientFn = getHandler('sep-2322-client-request-state'); @@ -38,4 +79,38 @@ describe('MRTR client scenario (SEP-2322)', () => { ).toBe('SUCCESS'); } }); + + test('a client that drops requestState on a requestState-only retry fails', async () => { + const checks = await runClientAgainstScenario( + new InlineClientRunner(stateDroppingClient), + 'sep-2322-client-request-state', + { expectedFailureSlugs: [STATE_ONLY_CHECK] } + ); + expect(checks.find((c) => c.id === STATE_ONLY_CHECK)?.errorMessage).toBe( + 'Client did not include requestState in retry' + ); + }); + + test('a client that rewrites requestState on a requestState-only retry fails', async () => { + const checks = await runClientAgainstScenario( + new InlineClientRunner(stateRewritingClient), + 'sep-2322-client-request-state', + { expectedFailureSlugs: [STATE_ONLY_CHECK] } + ); + expect( + checks.find((c) => c.id === STATE_ONLY_CHECK)?.errorMessage + ).toContain('requestState was not echoed back exactly'); + }); + + test('a client that never retries a requestState-only result fails', async () => { + const checks = await runClientAgainstScenario( + new InlineClientRunner(nonRetryingClient), + 'sep-2322-client-request-state', + { expectedFailureSlugs: [STATE_ONLY_CHECK] } + ); + const check = checks.find((c) => c.id === STATE_ONLY_CHECK); + expect(check?.status).toBe('FAILURE'); + expect(check?.errorMessage).toMatch(/^Not testable: /); + expect(check?.details?.untestable).toBe(true); + }); }); diff --git a/src/scenarios/client/mrtr-client.ts b/src/scenarios/client/mrtr-client.ts index 2bd5692e..bf1376b8 100644 --- a/src/scenarios/client/mrtr-client.ts +++ b/src/scenarios/client/mrtr-client.ts @@ -4,6 +4,7 @@ import type { ScenarioContext } from '../../mock-server'; * * Tests that clients correctly handle the MRTR (Multi-Round Tool Resolution) flow: * - Echo requestState back unchanged when retrying + * - Echo requestState back unchanged when the result carries no inputRequests * - Don't include requestState when server didn't send one * - Use a different JSON-RPC id on retry * @@ -15,6 +16,7 @@ import type { Scenario, ConformanceCheck } from '../../types'; import { DRAFT_PROTOCOL_VERSION, ScenarioUrls } from '../../types'; import express, { Request, Response } from 'express'; import { randomUUID } from 'crypto'; +import { untestableCheck } from '../untestable'; const MRTR_SPEC_REFERENCES = [ { @@ -44,6 +46,16 @@ const TOOLS = [ required: [] as string[] } }, + { + name: 'test_mrtr_state_only', + description: + 'Test tool: triggers MRTR flow with requestState and no inputRequests. Client must echo state back unchanged.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [] as string[] + } + }, { name: 'test_mrtr_unrelated', description: @@ -139,6 +151,11 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { return; } + if (toolName === 'test_mrtr_state_only') { + handleStateOnly(id, inputResponses, requestState, checks, res); + return; + } + if (toolName === 'test_mrtr_unrelated') { handleUnrelated(inputResponses, requestState, checks, res, id); return; @@ -356,6 +373,98 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application { }); } + function handleStateOnly( + id: string | number, + inputResponses: Record | undefined, + requestState: string | undefined, + checks: ConformanceCheck[], + res: Response + ) { + const checkId = 'sep-2322-client-request-state-only-echoed'; + const name = 'MRTRClientRequestStateOnlyEchoed'; + const description = + 'Client MUST echo back the exact value of requestState when retrying a result that has no inputRequests'; + + if (!originalIds.has('state_only')) { + // Initial call — return InputRequiredResult with requestState and NO + // inputRequests. The spec requires at least one of the two, so this is + // valid: the client has nothing to fulfill, only state to echo. + originalIds.set('state_only', id); + const state = JSON.stringify({ + nonce: randomUUID(), + originalId: id + }); + sentStates.set('state_only', state); + // Retrying is a MAY, so until a retry arrives the echo requirement + // cannot be checked. The retry replaces this check with the verdict. + checks.push( + untestableCheck( + checkId, + name, + description, + 'the client did not retry the InputRequiredResult that had requestState and no inputRequests', + MRTR_SPEC_REFERENCES + ) + ); + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'input_required', + requestState: state + // No inputRequests field! + } + }); + return; + } + + // Retry — a broken client may drop requestState, so the params cannot + // tell a retry from a fresh call. Every call after the first is a retry; + // only the first one is checked. + const pendingIdx = checks.findIndex( + (c) => c.id === checkId && c.details?.untestable === true + ); + if (pendingIdx !== -1) { + checks.splice(pendingIdx, 1); + const sentState = sentStates.get('state_only'); + const errors: string[] = []; + if (!requestState) { + errors.push('Client did not include requestState in retry'); + } else if (requestState !== sentState) { + errors.push( + 'requestState was not echoed back exactly — clients MUST NOT inspect, parse, or modify it' + ); + } + + checks.push({ + id: checkId, + name, + description, + status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', + timestamp: new Date().toISOString(), + errorMessage: errors.length > 0 ? errors.join('; ') : undefined, + specReferences: MRTR_SPEC_REFERENCES, + details: { + requestStateSent: sentState, + requestStateReceived: requestState, + inputResponsesReceived: inputResponses, + originalId: originalIds.get('state_only'), + retryId: id + } + }); + } + + // Return complete result + res.json({ + jsonrpc: '2.0', + id, + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'state-only-ok' }] + } + }); + } + function handleUnrelated( inputResponses: Record | undefined, requestState: string | undefined, @@ -492,6 +601,7 @@ export class MRTRClientScenario implements Scenario { getChecks(): ConformanceCheck[] { const expectedSlugs = [ 'sep-2322-client-request-state-echoed', + 'sep-2322-client-request-state-only-echoed', 'sep-2322-client-jsonrpc-id-different', 'sep-2322-client-no-state-omitted', 'sep-2322-client-parallel-isolation', diff --git a/src/seps/sep-2322.yaml b/src/seps/sep-2322.yaml index 143a7ed1..d3f7901d 100644 --- a/src/seps/sep-2322.yaml +++ b/src/seps/sep-2322.yaml @@ -37,6 +37,9 @@ requirements: - check: sep-2322-client-request-state-echoed text: 'If an InputRequiredResult contains the requestState field, the client MUST echo back the exact value of that field when retrying the original request. Clients MUST NOT inspect, parse, modify, or make any assumptions about the requestState contents.' + - check: sep-2322-client-request-state-only-echoed + text: 'If an InputRequiredResult contains the requestState field, the client MUST echo back the exact value of that field when retrying the original request. (requestState-only variant: the result has no inputRequests)' + - check: sep-2322-client-no-state-omitted text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry.'