fix(input-required-result): report the capability check as untestable when nothing is requested (#442)

`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

Co-authored-by: Marcus <238969485+marcus-kepler-92@users.noreply.github.com>
This commit is contained in:
marcus-kepler-92 2026-09-07 02:29:21 +08:00 • committed by GitHub
commit 48561a1aec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 4 deletions

View file

@ -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

View file

@ -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({

View file

@ -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);
});