fix(server-stateless): requiredCapabilities is a ClientCapabilities object, not an array (#376)

The sep-2575-server-rejects-undeclared-capability check required
error.data.requiredCapabilities to be an array containing 'sampling'. The
schema's MissingRequiredClientCapabilityError defines it as a
ClientCapabilities object ({ "sampling": {} }), as do this repo's own
spec-types and the tasks/required-task-error scenario, so a spec-correct
server could not pass the check. It shipped because the positive -32021
branch had no self-test.

- stateless.ts: accept the object shape; each capability value must itself
  be an object ({ "sampling": null } fails). State the shape in the
  scenario's implementer-facing description.
- examples/servers/typescript/everything-server.ts: emit the object.
- stateless.test.ts: self-tests for object-passes / array-fails /
  null-value-fails, sharing one capability-probe mock helper.
This commit is contained in:
Felix Weinberger 2026-07-01 17:46:14 +01:00 • committed by GitHub
commit 9524edcf41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 98 additions and 17 deletions

View file

@ -1663,7 +1663,9 @@ app.post('/mcp', async (req, res) => {
error: { error: {
code: -32021, code: -32021,
message: 'MissingRequiredClientCapabilityError', message: 'MissingRequiredClientCapabilityError',
data: { requiredCapabilities: ['sampling'] } // Per the schema, requiredCapabilities is a ClientCapabilities
// object keyed by the missing capability, not an array of names.
data: { requiredCapabilities: { sampling: {} } }
} }
}); });
} }

View file

@ -186,13 +186,16 @@ describe('Stateless Server Scenario Negative Tests', () => {
expect(statusCheck?.errorMessage).toContain('Not testable:'); expect(statusCheck?.errorMessage).toContain('Not testable:');
}); });
test('Fails the capability check when the listed diagnostic tool executes without -32021', async () => { // A server that lists test_missing_capability and answers its probe call
// This server lists test_missing_capability but happily executes it even // with the given response; shared by the capability-shape tests below so
// though the client never declared the sampling capability — a genuine // the fixture contract lives in one place.
// violation of the MUST, not an untestable gap. function capabilityProbeMock(
const mockUrl = mockFetchTarget((reqBody) => { label: string,
callResponse: { status: number; body: object }
) {
return mockFetchTarget((reqBody) => {
if (reqBody.method === 'server/discover') { if (reqBody.method === 'server/discover') {
return discoverResponse(reqBody, { tools: {} }, 'no-enforcement'); return discoverResponse(reqBody, { tools: {} }, label);
} }
if (reqBody.method === 'tools/list') { if (reqBody.method === 'tools/list') {
return { return {
@ -209,15 +212,29 @@ describe('Stateless Server Scenario Negative Tests', () => {
reqBody.params?.name === 'test_missing_capability' reqBody.params?.name === 'test_missing_capability'
) { ) {
return { return {
status: 200, status: callResponse.status,
body: { body: { jsonrpc: '2.0', id: reqBody.id, ...callResponse.body }
jsonrpc: '2.0',
id: reqBody.id,
result: { resultType: 'complete', content: [] }
}
}; };
} }
}); });
}
const spec32021 = (requiredCapabilities: unknown) => ({
error: {
code: -32021,
message: 'MissingRequiredClientCapabilityError',
data: { requiredCapabilities }
}
});
test('Fails the capability check when the listed diagnostic tool executes without -32021', async () => {
// This server lists test_missing_capability but happily executes it even
// though the client never declared the sampling capability — a genuine
// violation of the MUST, not an untestable gap.
const mockUrl = capabilityProbeMock('no-enforcement', {
status: 200,
body: { result: { resultType: 'complete', content: [] } }
});
const scenario = new ServerStatelessScenario(); const scenario = new ServerStatelessScenario();
const checks = await scenario.run(testContext(mockUrl)); const checks = await scenario.run(testContext(mockUrl));
@ -230,6 +247,62 @@ describe('Stateless Server Scenario Negative Tests', () => {
expect(rejectCheck?.errorMessage).toContain('MUST reject with -32021'); expect(rejectCheck?.errorMessage).toContain('MUST reject with -32021');
}); });
test('Passes the capability checks on a spec-shaped -32021: requiredCapabilities is a ClientCapabilities object', async () => {
// The schema's MissingRequiredClientCapabilityError carries
// `data.requiredCapabilities` as a ClientCapabilities OBJECT keyed by the
// missing capability (e.g. `{ "sampling": {} }`), not an array of names.
const mockUrl = capabilityProbeMock('spec-shaped-32021', {
status: 400,
body: spec32021({ sampling: {} })
});
const scenario = new ServerStatelessScenario();
const checks = await scenario.run(testContext(mockUrl));
expect(
findCheck(checks, 'sep-2575-server-rejects-undeclared-capability')?.status
).toBe('SUCCESS');
expect(
findCheck(checks, 'sep-2575-missing-capability-http-400')?.status
).toBe('SUCCESS');
});
test('Fails the capability check when requiredCapabilities is an array of names instead of the schema object', async () => {
const mockUrl = capabilityProbeMock('array-shaped-32021', {
status: 400,
body: spec32021(['sampling'])
});
const scenario = new ServerStatelessScenario();
const checks = await scenario.run(testContext(mockUrl));
const rejectCheck = findCheck(
checks,
'sep-2575-server-rejects-undeclared-capability'
);
expect(rejectCheck?.status).toBe('FAILURE');
expect(rejectCheck?.errorMessage).toContain('ClientCapabilities object');
});
test('Fails the capability check when the sampling capability value is not an object (e.g. null)', async () => {
// ClientCapabilities values are themselves objects; `{ sampling: null }`
// is schema-invalid and must not be certified.
const mockUrl = capabilityProbeMock('null-valued-32021', {
status: 400,
body: spec32021({ sampling: null })
});
const scenario = new ServerStatelessScenario();
const checks = await scenario.run(testContext(mockUrl));
const rejectCheck = findCheck(
checks,
'sep-2575-server-rejects-undeclared-capability'
);
expect(rejectCheck?.status).toBe('FAILURE');
expect(rejectCheck?.errorMessage).toContain('ClientCapabilities object');
});
test('Fails the subscription checks when listChanged is advertised but listen is rejected', async () => { test('Fails the subscription checks when listChanged is advertised but listen is rejected', async () => {
const mockUrl = mockFetchTarget((reqBody) => { const mockUrl = mockFetchTarget((reqBody) => {
if (reqBody.method === 'server/discover') { if (reqBody.method === 'server/discover') {

View file

@ -43,7 +43,7 @@ export class ServerStatelessScenario implements ClientScenario {
- Mismatched or unknown protocol versions must return an \`UnsupportedProtocolVersionError\` (HTTP status code \`400 Bad Request\`) carrying precise version tracking arrays. - Mismatched or unknown protocol versions must return an \`UnsupportedProtocolVersionError\` (HTTP status code \`400 Bad Request\`) carrying precise version tracking arrays.
- Absent or altered protocol version header metadata must trigger a \`-32020 Header Mismatch\` error with an HTTP 400 boundary state. - Absent or altered protocol version header metadata must trigger a \`-32020 Header Mismatch\` error with an HTTP 400 boundary state.
4. **Client Capability Constraints (2 Checks)** 4. **Client Capability Constraints (2 Checks)**
- Accessing platform capabilities without explicit declaration drops requests with a \`-32021 MissingRequiredClientCapabilityError\` containing needed capabilities, returning an HTTP status code \`400 Bad Request\`. - Accessing platform capabilities without explicit declaration drops requests with a \`-32021 MissingRequiredClientCapabilityError\` returning an HTTP status code \`400 Bad Request\`. Its \`error.data.requiredCapabilities\` is a \`ClientCapabilities\` object keyed by the missing capability (e.g. \`{ "sampling": {} }\`), not an array of names.
5. **Methods & Routing Mechanics (5 Checks)** 5. **Methods & Routing Mechanics (5 Checks)**
- Removed legacy endpoints (\`initialize\`, \`ping\`, \`logging/setLevel\`, etc.) or generic unknown methods must cleanly yield an HTTP status code \`404 Not Found\` alongside a JSON-RPC \`-32601 Method not found\` payload. All error returns must preserve original request ID mappings. - Removed legacy endpoints (\`initialize\`, \`ping\`, \`logging/setLevel\`, etc.) or generic unknown methods must cleanly yield an HTTP status code \`404 Not Found\` alongside a JSON-RPC \`-32601 Method not found\` payload. All error returns must preserve original request ID mappings.
- Validates response streams contain only \`IncompleteResult\` chunks and never independent top-level JSON-RPC requests, while enforcing that no log messages are emitted when \`_meta.../logLevel\` is omitted. - Validates response streams contain only \`IncompleteResult\` chunks and never independent top-level JSON-RPC requests, while enforcing that no log messages are emitted when \`_meta.../logLevel\` is omitted.
@ -693,11 +693,17 @@ export class ServerStatelessScenario implements ClientScenario {
}; };
} }
// If it DOES return -32021, strictly validate the requirement payload structure // If it DOES return -32021, strictly validate the requirement payload
// structure. Per the schema's `MissingRequiredClientCapabilityError`,
// `error.data.requiredCapabilities` is a `ClientCapabilities` OBJECT
// keyed by the missing capability — not an array of capability names —
// and each capability value is itself an object.
const reqCaps = data401?.error?.data?.requiredCapabilities; const reqCaps = data401?.error?.data?.requiredCapabilities;
if (!Array.isArray(reqCaps) || !reqCaps.includes('sampling')) { const isPlainObject = (value: unknown): boolean =>
typeof value === 'object' && value !== null && !Array.isArray(value);
if (!isPlainObject(reqCaps) || !isPlainObject(reqCaps.sampling)) {
return { return {
error: `Server responded with error code -32021 but failed to provide an array containing the expected 'sampling' capability in error.data.requiredCapabilities`, error: `Server responded with error code -32021 but error.data.requiredCapabilities is not a ClientCapabilities object naming 'sampling' (the schema defines it as an object of capability objects, e.g. { "sampling": {} }, not an array)`,
details: { response: data401 } details: { response: data401 }
}; };
} }