Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,32 @@ async function runMRTRClient(serverUrl: string): Promise<void> {
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<string, unknown>
| undefined;
if (rStateOnlyResult?.resultType === 'input_required') {
const requestState = rStateOnlyResult.requestState as string | undefined;

const retryParams: Record<string, unknown> = {
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',
Expand Down
75 changes: 75 additions & 0 deletions src/scenarios/client/mrtr-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
): Promise<Record<string, unknown> | 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<string, unknown> };
return body.result;
}

// Retries the requestState-only round without echoing the state.
async function stateDroppingClient(serverUrl: string): Promise<void> {
await callStateOnlyTool(serverUrl, 1);
await callStateOnlyTool(serverUrl, 2);
}

// Re-serializes the state before echoing it.
async function stateRewritingClient(serverUrl: string): Promise<void> {
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<void> {
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');
Expand All @@ -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);
});
});
110 changes: 110 additions & 0 deletions src/scenarios/client/mrtr-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -356,6 +373,98 @@ function createMRTRServer(checks: ConformanceCheck[]): express.Application {
});
}

function handleStateOnly(
id: string | number,
inputResponses: Record<string, unknown> | 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<string, unknown> | undefined,
requestState: string | undefined,
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/seps/sep-2322.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'

Expand Down