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:
parent
4944b26806
commit
9524edcf41
3 changed files with 98 additions and 17 deletions
|
|
@ -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: {} } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue