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: {
code: -32021,
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:');
});
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 = mockFetchTarget((reqBody) => {
// A server that lists test_missing_capability and answers its probe call
// with the given response; shared by the capability-shape tests below so
// the fixture contract lives in one place.
function capabilityProbeMock(
label: string,
callResponse: { status: number; body: object }
) {
return mockFetchTarget((reqBody) => {
if (reqBody.method === 'server/discover') {
return discoverResponse(reqBody, { tools: {} }, 'no-enforcement');
return discoverResponse(reqBody, { tools: {} }, label);
}
if (reqBody.method === 'tools/list') {
return {
@ -209,15 +212,29 @@ describe('Stateless Server Scenario Negative Tests', () => {
reqBody.params?.name === 'test_missing_capability'
) {
return {
status: 200,
body: {
jsonrpc: '2.0',
id: reqBody.id,
result: { resultType: 'complete', content: [] }
}
status: callResponse.status,
body: { jsonrpc: '2.0', id: reqBody.id, ...callResponse.body }
};
}
});
}
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 checks = await scenario.run(testContext(mockUrl));
@ -230,6 +247,62 @@ describe('Stateless Server Scenario Negative Tests', () => {
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 () => {
const mockUrl = mockFetchTarget((reqBody) => {
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.
- 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)**
- 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)**
- 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.
@ -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;
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 {
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 }
};
}