From 67fca9583b620a61d80b501783a746dd92d61870 Mon Sep 17 00:00:00 2001 From: Marcus <238969485+marcus-kepler-92@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:25:30 +0800 Subject: [PATCH] fix(input-required-result): report the capability check as untestable when nothing is requested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `input-required-result-capability-check` guarded its elicitation scan with `isInputRequiredResult(result) && result.inputRequests`. When a server answered without `inputRequests`, that arm was skipped, and the following `isCompleteResult` arm is false by construction for an input_required result, so nothing was recorded and the check passed. An empty `inputRequests` object passed too: the arm was taken, but the loop had nothing to scan. `inputRequests` is optional — a result carrying only `requestState` still satisfies "at least one of inputRequests or requestState" — so such a server has not violated "servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities". It has not exercised it either. Reporting SUCCESS makes an unverified MUST read as green, which is the failure mode #248 and #372 established should be reported as a failure instead. So report it through `notTestable()` with `details.untestable`, matching the convention in AGENTS.md and the call sites in stateless.ts, rather than claiming a violation the server did not commit. Servers that legitimately have nothing to ask for now go red on this check; per #248 the expected-failures baseline is the escape hatch. Adds the case to sep-2322-mrtr-broken-server so the existing MRTR negative suite covers it. The fixture returns a conformant envelope (`requestState` present, no input requests) rather than a bare one, so it isolates this check rather than also tripping sep-2322-request-state-incomplete. Verified against --spec-version 2026-07-28: everything-server 2/2 passed sep-2322-mrtr-broken-server FAILURE Not testable: server returned no inputRequests, so the capability restriction was never exercised Refs #439 --- .../typescript/sep-2322-mrtr-broken-server.ts | 20 ++++++++++++++++++ src/scenarios/server/input-required-result.ts | 21 ++++++++++++++++--- src/scenarios/server/negative-mrtr.test.ts | 17 ++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts index 49fcdb6d..72cf6021 100644 --- a/examples/servers/typescript/sep-2322-mrtr-broken-server.ts +++ b/examples/servers/typescript/sep-2322-mrtr-broken-server.ts @@ -8,6 +8,11 @@ * 2. Returns InputRequiredResult on `tools/list` (unsupported method) * 3. Accepts tampered requestState without integrity verification * + * Plus one case that is not a violation at all: + * + * 4. Answers with a conformant input_required result that names no input + * request, leaving the capability check with nothing to verify. + * * The conformance scenarios should emit FAILURE against this server. */ @@ -54,6 +59,11 @@ handlers['tools/list'] = () => ({ name: 'test_input_required_result_tampered_state', description: 'Test tool for tampered state', inputSchema: { type: 'object' as const, properties: {} } + }, + { + name: 'test_input_required_result_capabilities', + description: 'Test tool for client capability handling', + inputSchema: { type: 'object' as const, properties: {} } } ] }); @@ -94,6 +104,16 @@ handlers['tools/call'] = (params) => { }; } + case 'test_input_required_result_capabilities': { + // BUG 4: Conformant on its face — `requestState` satisfies "at least one + // of inputRequests or requestState" — but it names no input request, so + // the capability restriction under test is never exercised. + return { + resultType: 'input_required', + requestState: 'no-input-requested' + }; + } + 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 66efb3f5..64ad4ba9 100644 --- a/src/scenarios/server/input-required-result.ts +++ b/src/scenarios/server/input-required-result.ts @@ -22,6 +22,7 @@ import { mockListRootsResponse, MRTR_SPEC_REFERENCES } from './input-required-result-helpers'; +import { notTestable } from '../untestable'; // ─── A1: Basic Elicitation ──────────────────────────────────────────────────── @@ -1424,14 +1425,28 @@ Only include inputRequests for methods the client supports. For example, if the const result = resp.result; const errors: string[] = []; + let untestable = false; if (resp.error) { errors.push(`JSON-RPC error: ${resp.error.message}`); } else if (!result) { errors.push('No result in response'); - } else if (isInputRequiredResult(result) && result.inputRequests) { + } else if (isInputRequiredResult(result)) { + const inputRequests = Object.entries(result.inputRequests ?? {}); + // `inputRequests` is optional (a result carrying only `requestState` is + // still valid), so naming none does not violate the MUST NOT this check + // scores — but it leaves nothing to scan, and the loop below would then + // record no error at all, scoring SUCCESS without having verified it. + if (inputRequests.length === 0) { + untestable = true; + errors.push( + notTestable( + 'server returned no inputRequests, so the capability restriction was never exercised' + ) + ); + } // Check that no elicitation requests are included (client didn't declare it) - for (const [key, req] of Object.entries(result.inputRequests)) { + for (const [key, req] of inputRequests) { if (req.method === 'elicitation/create') { errors.push( `Server included elicitation/create inputRequest (key: "${key}") ` + @@ -1454,7 +1469,7 @@ Only include inputRequests for methods the client supports. For example, if the timestamp: new Date().toISOString(), errorMessage: errors.length > 0 ? errors.join('; ') : undefined, specReferences: MRTR_SPEC_REFERENCES, - details: { result } + details: untestable ? { result, untestable: true } : { result } }); } catch (error) { checks.push({ diff --git a/src/scenarios/server/negative-mrtr.test.ts b/src/scenarios/server/negative-mrtr.test.ts index 30228400..871ad73b 100644 --- a/src/scenarios/server/negative-mrtr.test.ts +++ b/src/scenarios/server/negative-mrtr.test.ts @@ -13,7 +13,8 @@ import path from 'path'; import { InputRequiredResultResultTypeScenario, InputRequiredResultUnsupportedMethodsScenario, - InputRequiredResultTamperedStateScenario + InputRequiredResultTamperedStateScenario, + InputRequiredResultCapabilityCheckScenario } from './input-required-result'; import { formatWireViolation, @@ -142,4 +143,18 @@ describe('SEP-2322 MRTR negative tests', () => { expect(tamperedCheck).toBeDefined(); expect(tamperedCheck?.status).toBe('FAILURE'); }, 10000); + + it('reports sep-2322-respect-client-capabilities as untestable against a server whose input_required result requests nothing', async () => { + const scenario = new InputRequiredResultCapabilityCheckScenario(); + const checks = await scenario.run(testContext(SERVER_URL)); + + const capabilityCheck = checks.find( + (c) => c.id === 'sep-2322-respect-client-capabilities' + ); + expect(capabilityCheck).toBeDefined(); + expect(capabilityCheck?.status).toBe('FAILURE'); + // The requirement was not violated, it could not be exercised (#248). + expect(capabilityCheck?.errorMessage).toContain('Not testable:'); + expect(capabilityCheck?.details?.untestable).toBe(true); + }, 10000); });