Skip to content

Commit 0df43f7

Browse files
committed
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.
1 parent c37eec8 commit 0df43f7

4 files changed

Lines changed: 214 additions & 1 deletion

File tree

‎examples/clients/typescript/everything-client.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1052,7 +1052,32 @@ async function runMRTRClient(serverUrl: string): Promise<void> {
10521052
logger.debug('test_mrtr_no_state: MRTR flow completed');
10531053
}
10541054

1055-
// Tool 3: test_mrtr_no_result_type — returns result without resultType field
1055+
// Tool 3: test_mrtr_state_only — call, get InputRequiredResult with requestState
1056+
// and no inputRequests; nothing to fulfill, so retry with only the state echoed
1057+
const rStateOnly = await sendRpc('tools/call', {
1058+
name: 'test_mrtr_state_only',
1059+
arguments: {}
1060+
});
1061+
1062+
const rStateOnlyResult = rStateOnly.result as
1063+
| Record<string, unknown>
1064+
| undefined;
1065+
if (rStateOnlyResult?.resultType === 'input_required') {
1066+
const requestState = rStateOnlyResult.requestState as string | undefined;
1067+
1068+
const retryParams: Record<string, unknown> = {
1069+
name: 'test_mrtr_state_only',
1070+
arguments: {}
1071+
};
1072+
if (requestState !== undefined) {
1073+
retryParams.requestState = requestState;
1074+
}
1075+
1076+
await sendRpc('tools/call', retryParams);
1077+
logger.debug('test_mrtr_state_only: MRTR flow completed');
1078+
}
1079+
1080+
// Tool 4: test_mrtr_no_result_type — returns result without resultType field
10561081
// Client must treat it as complete (default) and NOT retry
10571082
const r3 = await sendRpc('tools/call', {
10581083
name: 'test_mrtr_no_result_type',

‎src/scenarios/client/mrtr-client.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,47 @@ import {
1212
import { getHandler } from '../../../examples/clients/typescript/everything-client';
1313
import { getScenario } from '../index';
1414

15+
const STATE_ONLY_CHECK = 'sep-2322-client-request-state-only-echoed';
16+
17+
async function callStateOnlyTool(
18+
serverUrl: string,
19+
id: number,
20+
extraParams: Record<string, unknown> = {}
21+
): Promise<Record<string, unknown> | undefined> {
22+
const resp = await fetch(serverUrl, {
23+
method: 'POST',
24+
headers: { 'Content-Type': 'application/json' },
25+
body: JSON.stringify({
26+
jsonrpc: '2.0',
27+
id,
28+
method: 'tools/call',
29+
params: { name: 'test_mrtr_state_only', arguments: {}, ...extraParams }
30+
})
31+
});
32+
const body = (await resp.json()) as { result?: Record<string, unknown> };
33+
return body.result;
34+
}
35+
36+
// Retries the requestState-only round without echoing the state.
37+
async function stateDroppingClient(serverUrl: string): Promise<void> {
38+
await callStateOnlyTool(serverUrl, 1);
39+
await callStateOnlyTool(serverUrl, 2);
40+
}
41+
42+
// Re-serializes the state before echoing it.
43+
async function stateRewritingClient(serverUrl: string): Promise<void> {
44+
const result = await callStateOnlyTool(serverUrl, 1);
45+
const state = result?.requestState as string;
46+
await callStateOnlyTool(serverUrl, 2, {
47+
requestState: JSON.stringify(JSON.parse(state), null, 1)
48+
});
49+
}
50+
51+
// Treats a result without inputRequests as final and never retries.
52+
async function nonRetryingClient(serverUrl: string): Promise<void> {
53+
await callStateOnlyTool(serverUrl, 1);
54+
}
55+
1556
describe('MRTR client scenario (SEP-2322)', () => {
1657
test('everything-client passes sep-2322-client-request-state scenario', async () => {
1758
const clientFn = getHandler('sep-2322-client-request-state');
@@ -38,4 +79,38 @@ describe('MRTR client scenario (SEP-2322)', () => {
3879
).toBe('SUCCESS');
3980
}
4081
});
82+
83+
test('a client that drops requestState on a requestState-only retry fails', async () => {
84+
const checks = await runClientAgainstScenario(
85+
new InlineClientRunner(stateDroppingClient),
86+
'sep-2322-client-request-state',
87+
{ expectedFailureSlugs: [STATE_ONLY_CHECK] }
88+
);
89+
expect(checks.find((c) => c.id === STATE_ONLY_CHECK)?.errorMessage).toBe(
90+
'Client did not include requestState in retry'
91+
);
92+
});
93+
94+
test('a client that rewrites requestState on a requestState-only retry fails', async () => {
95+
const checks = await runClientAgainstScenario(
96+
new InlineClientRunner(stateRewritingClient),
97+
'sep-2322-client-request-state',
98+
{ expectedFailureSlugs: [STATE_ONLY_CHECK] }
99+
);
100+
expect(
101+
checks.find((c) => c.id === STATE_ONLY_CHECK)?.errorMessage
102+
).toContain('requestState was not echoed back exactly');
103+
});
104+
105+
test('a client that never retries a requestState-only result fails', async () => {
106+
const checks = await runClientAgainstScenario(
107+
new InlineClientRunner(nonRetryingClient),
108+
'sep-2322-client-request-state',
109+
{ expectedFailureSlugs: [STATE_ONLY_CHECK] }
110+
);
111+
const check = checks.find((c) => c.id === STATE_ONLY_CHECK);
112+
expect(check?.status).toBe('FAILURE');
113+
expect(check?.errorMessage).toMatch(/^Not testable: /);
114+
expect(check?.details?.untestable).toBe(true);
115+
});
41116
});

‎src/scenarios/client/mrtr-client.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ScenarioContext } from '../../mock-server';
44
*
55
* Tests that clients correctly handle the MRTR (Multi-Round Tool Resolution) flow:
66
* - Echo requestState back unchanged when retrying
7+
* - Echo requestState back unchanged when the result carries no inputRequests
78
* - Don't include requestState when server didn't send one
89
* - Use a different JSON-RPC id on retry
910
*
@@ -15,6 +16,7 @@ import type { Scenario, ConformanceCheck } from '../../types';
1516
import { DRAFT_PROTOCOL_VERSION, ScenarioUrls } from '../../types';
1617
import express, { Request, Response } from 'express';
1718
import { randomUUID } from 'crypto';
19+
import { untestableCheck } from '../untestable';
1820

1921
const MRTR_SPEC_REFERENCES = [
2022
{
@@ -44,6 +46,16 @@ const TOOLS = [
4446
required: [] as string[]
4547
}
4648
},
49+
{
50+
name: 'test_mrtr_state_only',
51+
description:
52+
'Test tool: triggers MRTR flow with requestState and no inputRequests. Client must echo state back unchanged.',
53+
inputSchema: {
54+
type: 'object' as const,
55+
properties: {},
56+
required: [] as string[]
57+
}
58+
},
4759
{
4860
name: 'test_mrtr_unrelated',
4961
description:
@@ -139,6 +151,11 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application {
139151
return;
140152
}
141153

154+
if (toolName === 'test_mrtr_state_only') {
155+
handleStateOnly(id, inputResponses, requestState, checks, res);
156+
return;
157+
}
158+
142159
if (toolName === 'test_mrtr_unrelated') {
143160
handleUnrelated(inputResponses, requestState, checks, res, id);
144161
return;
@@ -356,6 +373,98 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application {
356373
});
357374
}
358375

376+
function handleStateOnly(
377+
id: string | number,
378+
inputResponses: Record<string, unknown> | undefined,
379+
requestState: string | undefined,
380+
checks: ConformanceCheck[],
381+
res: Response
382+
) {
383+
const checkId = 'sep-2322-client-request-state-only-echoed';
384+
const name = 'MRTRClientRequestStateOnlyEchoed';
385+
const description =
386+
'Client MUST echo back the exact value of requestState when retrying a result that has no inputRequests';
387+
388+
if (!originalIds.has('state_only')) {
389+
// Initial call — return InputRequiredResult with requestState and NO
390+
// inputRequests. The spec requires at least one of the two, so this is
391+
// valid: the client has nothing to fulfill, only state to echo.
392+
originalIds.set('state_only', id);
393+
const state = JSON.stringify({
394+
nonce: randomUUID(),
395+
originalId: id
396+
});
397+
sentStates.set('state_only', state);
398+
// Retrying is a MAY, so until a retry arrives the echo requirement
399+
// cannot be checked. The retry replaces this check with the verdict.
400+
checks.push(
401+
untestableCheck(
402+
checkId,
403+
name,
404+
description,
405+
'the client did not retry the InputRequiredResult that had requestState and no inputRequests',
406+
MRTR_SPEC_REFERENCES
407+
)
408+
);
409+
res.json({
410+
jsonrpc: '2.0',
411+
id,
412+
result: {
413+
resultType: 'input_required',
414+
requestState: state
415+
// No inputRequests field!
416+
}
417+
});
418+
return;
419+
}
420+
421+
// Retry — a broken client may drop requestState, so the params cannot
422+
// tell a retry from a fresh call. Every call after the first is a retry;
423+
// only the first one is checked.
424+
const pendingIdx = checks.findIndex(
425+
(c) => c.id === checkId && c.details?.untestable === true
426+
);
427+
if (pendingIdx !== -1) {
428+
checks.splice(pendingIdx, 1);
429+
const sentState = sentStates.get('state_only');
430+
const errors: string[] = [];
431+
if (!requestState) {
432+
errors.push('Client did not include requestState in retry');
433+
} else if (requestState !== sentState) {
434+
errors.push(
435+
'requestState was not echoed back exactly — clients MUST NOT inspect, parse, or modify it'
436+
);
437+
}
438+
439+
checks.push({
440+
id: checkId,
441+
name,
442+
description,
443+
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
444+
timestamp: new Date().toISOString(),
445+
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
446+
specReferences: MRTR_SPEC_REFERENCES,
447+
details: {
448+
requestStateSent: sentState,
449+
requestStateReceived: requestState,
450+
inputResponsesReceived: inputResponses,
451+
originalId: originalIds.get('state_only'),
452+
retryId: id
453+
}
454+
});
455+
}
456+
457+
// Return complete result
458+
res.json({
459+
jsonrpc: '2.0',
460+
id,
461+
result: {
462+
resultType: 'complete',
463+
content: [{ type: 'text', text: 'state-only-ok' }]
464+
}
465+
});
466+
}
467+
359468
function handleUnrelated(
360469
inputResponses: Record<string, unknown> | undefined,
361470
requestState: string | undefined,
@@ -492,6 +601,7 @@ export class MRTRClientScenario implements Scenario {
492601
getChecks(): ConformanceCheck[] {
493602
const expectedSlugs = [
494603
'sep-2322-client-request-state-echoed',
604+
'sep-2322-client-request-state-only-echoed',
495605
'sep-2322-client-jsonrpc-id-different',
496606
'sep-2322-client-no-state-omitted',
497607
'sep-2322-client-parallel-isolation',

‎src/seps/sep-2322.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ requirements:
3737
- check: sep-2322-client-request-state-echoed
3838
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.'
3939

40+
- check: sep-2322-client-request-state-only-echoed
41+
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)'
42+
4043
- check: sep-2322-client-no-state-omitted
4144
text: 'If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry.'
4245

0 commit comments

Comments
 (0)