fix(sep-2243): map custom-header check IDs to their requirements (#303)
The x-mcp-header / Mcp-Param / Base64 half of SEP-2243 was already tested, but the scenarios emitted one check ID per test case while sep-2243.yaml declares one per normative requirement, so the traceability join (exact ID match) reported 14 of 20 requirements as untested. #287 collapsed the standard-header IDs but left the custom-header ones per-case. This finishes the rename and adds DECLARED_CHECK_IDS backfill (the request-metadata.ts pattern) so every requirement-level ID is emitted on every run — exercised, backfilled as FAILURE, or SKIPPED when the server under test exposes no x-mcp-header tool. SEP-2243 traceability goes from 6/20 to 18/20 tested; the remaining 2 (server-not-expect-null, server-reject-missing-required) have no test case yet. Client scenarios: - param-header-{name} -> client-mirrors-designated-params / client-encode-values / client-base64-unsafe, picked by what the parameter exercises - no-mirror-unannotated -> client-mirrors-designated-params - param-header-tool-call-gate -> client-supports-custom-headers, now also emitted SUCCESS when the annotated tool is called with Mcp-Param headers - the invalid-tool loop emits the specific x-mcp-header constraint each tool violates (not-empty / charset / unique / primitive-only) Server scenarios: - accepts-valid-base64 -> server-decode-base64 - rejects-invalid-base64-{padding,chars} -> server-reject-invalid-param-chars - literal-missing-base64-{prefix,suffix} -> server-validate-param-match - rejects-missing-custom-header -> server-validate-param-match - every custom-header rejection's -32001 check -> server-reject-param-mismatch - standard-header reject cases pass the coarse ID through the catch path too
This commit is contained in:
parent
d31a0299af
commit
909ea0b5fd
3 changed files with 399 additions and 64 deletions
200
src/scenarios/client/http-custom-headers.test.ts
Normal file
200
src/scenarios/client/http-custom-headers.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
HttpCustomHeadersScenario,
|
||||||
|
HttpInvalidToolHeadersScenario,
|
||||||
|
CUSTOM_HEADERS_DECLARED_CHECK_IDS,
|
||||||
|
INVALID_TOOL_DECLARED_CHECK_IDS
|
||||||
|
} from './http-custom-headers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the SEP-2243 requirement-level check IDs emitted by the custom-header
|
||||||
|
* client scenarios so the traceability manifest's join (yaml `check:` ==
|
||||||
|
* emitted id) cannot silently drift again. Each declared ID must be emitted
|
||||||
|
* on every run — exercised, backfilled as FAILURE, or SKIPPED — never absent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function post(
|
||||||
|
serverUrl: string,
|
||||||
|
body: object,
|
||||||
|
headers: Record<string, string> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await fetch(serverUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json, text/event-stream',
|
||||||
|
...headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idsOf(checks: { id: string }[]): Set<string> {
|
||||||
|
return new Set(checks.map((c) => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusesFor(
|
||||||
|
checks: { id: string; status: string }[],
|
||||||
|
id: string
|
||||||
|
): string[] {
|
||||||
|
return checks.filter((c) => c.id === id).map((c) => c.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HttpCustomHeadersScenario (SEP-2243) check IDs', () => {
|
||||||
|
it('emits exactly the declared requirement IDs as FAILURE when the client never connects', async () => {
|
||||||
|
const scenario = new HttpCustomHeadersScenario();
|
||||||
|
await scenario.start();
|
||||||
|
try {
|
||||||
|
const checks = scenario.getChecks();
|
||||||
|
expect(idsOf(checks)).toEqual(new Set(CUSTOM_HEADERS_DECLARED_CHECK_IDS));
|
||||||
|
for (const check of checks) {
|
||||||
|
expect(check.status).toBe('FAILURE');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await scenario.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps each parameter kind to its requirement ID on a conforming tool call', async () => {
|
||||||
|
const scenario = new HttpCustomHeadersScenario();
|
||||||
|
const { serverUrl } = await scenario.start();
|
||||||
|
try {
|
||||||
|
const nonAscii = 'Hello, 世界';
|
||||||
|
const nonAsciiB64 = Buffer.from(nonAscii, 'utf-8').toString('base64');
|
||||||
|
await post(
|
||||||
|
serverUrl,
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'test_custom_headers',
|
||||||
|
arguments: {
|
||||||
|
region: 'us-west1',
|
||||||
|
priority: 42,
|
||||||
|
non_ascii_val: nonAscii,
|
||||||
|
query: 'SELECT 1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Mcp-Method': 'tools/call',
|
||||||
|
'Mcp-Name': 'test_custom_headers',
|
||||||
|
'Mcp-Param-Region': 'us-west1',
|
||||||
|
'Mcp-Param-Priority': '42',
|
||||||
|
'Mcp-Param-NonAscii': `=?base64?${nonAsciiB64}?=`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await post(
|
||||||
|
serverUrl,
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'test_custom_headers_null',
|
||||||
|
arguments: {
|
||||||
|
region: 'us-east1',
|
||||||
|
priority: 1,
|
||||||
|
verbose: null,
|
||||||
|
query: 'SELECT 1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'Mcp-Method': 'tools/call',
|
||||||
|
'Mcp-Name': 'test_custom_headers_null',
|
||||||
|
'Mcp-Param-Region': 'us-east1',
|
||||||
|
'Mcp-Param-Priority': '1'
|
||||||
|
// Mcp-Param-Verbose deliberately omitted: value is null
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const checks = scenario.getChecks();
|
||||||
|
for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) {
|
||||||
|
const statuses = statusesFor(checks, id);
|
||||||
|
expect(statuses.length, id).toBeGreaterThan(0);
|
||||||
|
expect(statuses, id).not.toContain('FAILURE');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await scenario.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILs client-mirrors-designated-params when an annotated header is missing', async () => {
|
||||||
|
const scenario = new HttpCustomHeadersScenario();
|
||||||
|
const { serverUrl } = await scenario.start();
|
||||||
|
try {
|
||||||
|
await post(
|
||||||
|
serverUrl,
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'test_custom_headers',
|
||||||
|
arguments: { region: 'us-west1', priority: 42, query: 'SELECT 1' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mcp-Param-Region deliberately omitted
|
||||||
|
'Mcp-Param-Priority': '42'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const checks = scenario.getChecks();
|
||||||
|
expect(
|
||||||
|
statusesFor(checks, 'sep-2243-client-mirrors-designated-params')
|
||||||
|
).toContain('FAILURE');
|
||||||
|
} finally {
|
||||||
|
await scenario.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HttpInvalidToolHeadersScenario (SEP-2243) check IDs', () => {
|
||||||
|
it('emits every x-mcp-header constraint ID, SUCCESS when only valid_tool is called', async () => {
|
||||||
|
const scenario = new HttpInvalidToolHeadersScenario();
|
||||||
|
const { serverUrl } = await scenario.start();
|
||||||
|
try {
|
||||||
|
await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||||
|
await post(serverUrl, {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'valid_tool', arguments: { region: 'us-west1' } }
|
||||||
|
});
|
||||||
|
const checks = scenario.getChecks();
|
||||||
|
for (const id of INVALID_TOOL_DECLARED_CHECK_IDS) {
|
||||||
|
const statuses = statusesFor(checks, id);
|
||||||
|
expect(statuses.length, id).toBeGreaterThan(0);
|
||||||
|
expect(statuses, id).not.toContain('FAILURE');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await scenario.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FAILs the violated constraint ID when the client calls an invalid tool', async () => {
|
||||||
|
const scenario = new HttpInvalidToolHeadersScenario();
|
||||||
|
const { serverUrl } = await scenario.start();
|
||||||
|
try {
|
||||||
|
await post(serverUrl, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||||
|
await post(serverUrl, {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'invalid_empty_header', arguments: { value: 'x' } }
|
||||||
|
});
|
||||||
|
const checks = scenario.getChecks();
|
||||||
|
expect(statusesFor(checks, 'sep-2243-x-mcp-header-not-empty')).toContain(
|
||||||
|
'FAILURE'
|
||||||
|
);
|
||||||
|
// The other constraints were not violated.
|
||||||
|
expect(
|
||||||
|
statusesFor(checks, 'sep-2243-x-mcp-header-charset')
|
||||||
|
).not.toContain('FAILURE');
|
||||||
|
} finally {
|
||||||
|
await scenario.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,6 +29,52 @@ const SPEC_REFERENCE_TOOL_DEF = {
|
||||||
url: 'https://modelcontextprotocol.io/specification/draft/server/tools#x-mcp-header'
|
url: 'https://modelcontextprotocol.io/specification/draft/server/tools#x-mcp-header'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every requirement-level check ID HttpCustomHeadersScenario can emit
|
||||||
|
* (SEP-2243). Declared-but-unemitted checks are backfilled as FAILURE by
|
||||||
|
* getChecks() — same pattern as request-metadata.ts — so the emitted ID set
|
||||||
|
* is stable for traceability even when the client never exercises the
|
||||||
|
* feature.
|
||||||
|
*/
|
||||||
|
export const CUSTOM_HEADERS_DECLARED_CHECK_IDS = [
|
||||||
|
'sep-2243-client-supports-custom-headers',
|
||||||
|
'sep-2243-client-mirrors-designated-params',
|
||||||
|
'sep-2243-client-encode-values',
|
||||||
|
'sep-2243-client-base64-unsafe',
|
||||||
|
'sep-2243-client-omit-null'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every requirement-level check ID HttpInvalidToolHeadersScenario can emit
|
||||||
|
* (SEP-2243).
|
||||||
|
*/
|
||||||
|
export const INVALID_TOOL_DECLARED_CHECK_IDS = [
|
||||||
|
'sep-2243-client-reject-invalid-tool',
|
||||||
|
'sep-2243-x-mcp-header-not-empty',
|
||||||
|
'sep-2243-x-mcp-header-charset',
|
||||||
|
'sep-2243-x-mcp-header-unique',
|
||||||
|
'sep-2243-x-mcp-header-primitive-only'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid tool definitions served by HttpInvalidToolHeadersScenario, keyed by
|
||||||
|
* tool name, mapped to the SEP-2243 x-mcp-header constraint each one
|
||||||
|
* violates. The check for "client did not call this tool" is emitted under
|
||||||
|
* the constraint's requirement ID so each constraint traces to a check.
|
||||||
|
*/
|
||||||
|
const INVALID_TOOL_CONSTRAINT_IDS: Record<string, string> = {
|
||||||
|
invalid_empty_header: 'sep-2243-x-mcp-header-not-empty',
|
||||||
|
invalid_object_header: 'sep-2243-x-mcp-header-primitive-only',
|
||||||
|
invalid_array_header: 'sep-2243-x-mcp-header-primitive-only',
|
||||||
|
invalid_null_header: 'sep-2243-x-mcp-header-primitive-only',
|
||||||
|
invalid_duplicate_same_case: 'sep-2243-x-mcp-header-unique',
|
||||||
|
invalid_duplicate_diff_case: 'sep-2243-x-mcp-header-unique',
|
||||||
|
invalid_space_in_name: 'sep-2243-x-mcp-header-charset',
|
||||||
|
invalid_colon_in_name: 'sep-2243-x-mcp-header-charset',
|
||||||
|
invalid_non_ascii_name: 'sep-2243-x-mcp-header-charset',
|
||||||
|
invalid_control_char_name: 'sep-2243-x-mcp-header-charset'
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes a header value that may be Base64-encoded.
|
* Decodes a header value that may be Base64-encoded.
|
||||||
* Base64-encoded values use the format: =?base64?{Base64EncodedValue}?=
|
* Base64-encoded values use the format: =?base64?{Base64EncodedValue}?=
|
||||||
|
|
@ -182,28 +228,24 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
|
||||||
}
|
}
|
||||||
|
|
||||||
getChecks(): ConformanceCheck[] {
|
getChecks(): ConformanceCheck[] {
|
||||||
if (!this.toolCallReceived) {
|
// Declared-but-unemitted -> FAILURE (request-metadata.ts pattern). Keeps
|
||||||
|
// the emitted ID set stable for traceability even when the client never
|
||||||
|
// calls the annotated tools. The `some()` guard makes this idempotent.
|
||||||
|
for (const id of CUSTOM_HEADERS_DECLARED_CHECK_IDS) {
|
||||||
|
if (this.checks.some((c) => c.id === id)) continue;
|
||||||
|
const missingNullCall =
|
||||||
|
id === 'sep-2243-client-omit-null' && !this.nullToolCallReceived;
|
||||||
this.checks.push({
|
this.checks.push({
|
||||||
id: 'sep-2243-param-header-tool-call-gate',
|
id,
|
||||||
name: 'ClientCustomHeaderToolCall',
|
name: 'NotObserved',
|
||||||
description: 'Client calls the tool with x-mcp-header annotations',
|
description: `Declared check ${id} was never emitted`,
|
||||||
status: 'FAILURE',
|
status: 'FAILURE',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
errorMessage:
|
errorMessage: missingNullCall
|
||||||
'Client did not send a tools/call request for test_custom_headers.',
|
? 'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.'
|
||||||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
: this.toolCallReceived
|
||||||
});
|
? 'Check was not observed: no tool call exercised it.'
|
||||||
}
|
: 'Client did not send a tools/call request for test_custom_headers.',
|
||||||
if (!this.nullToolCallReceived) {
|
|
||||||
this.checks.push({
|
|
||||||
id: 'sep-2243-client-omit-null',
|
|
||||||
name: 'ClientCustomHeaderOmitNull',
|
|
||||||
description:
|
|
||||||
'Client MUST omit Mcp-Param header when parameter value is null or not provided',
|
|
||||||
status: 'FAILURE',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
errorMessage:
|
|
||||||
'Client did not send a tools/call request for test_custom_headers_null to test null/omitted parameter handling.',
|
|
||||||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -380,6 +422,26 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
|
||||||
if (toolName === 'test_custom_headers') {
|
if (toolName === 'test_custom_headers') {
|
||||||
this.toolCallReceived = true;
|
this.toolCallReceived = true;
|
||||||
|
|
||||||
|
// SEP-2243 "MCP clients MUST support this feature": observable as the
|
||||||
|
// client calling the annotated tool with at least one mirrored
|
||||||
|
// Mcp-Param-* header. The per-parameter checks below then validate each
|
||||||
|
// mirrored value individually.
|
||||||
|
const hasAnyParamHeader = Object.keys(req.headers).some((h) =>
|
||||||
|
h.startsWith('mcp-param-')
|
||||||
|
);
|
||||||
|
this.checks.push({
|
||||||
|
id: 'sep-2243-client-supports-custom-headers',
|
||||||
|
name: 'ClientSupportsCustomHeaders',
|
||||||
|
description:
|
||||||
|
'Client supports custom headers: calls the x-mcp-header annotated tool and mirrors at least one parameter',
|
||||||
|
status: hasAnyParamHeader ? 'SUCCESS' : 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errorMessage: hasAnyParamHeader
|
||||||
|
? undefined
|
||||||
|
: 'Client called test_custom_headers but sent no Mcp-Param-* headers.',
|
||||||
|
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||||
|
});
|
||||||
|
|
||||||
// Check Mcp-Param-Region header (plain ASCII string)
|
// Check Mcp-Param-Region header (plain ASCII string)
|
||||||
this.checkParamHeader(req, 'Region', args.region, 'string');
|
this.checkParamHeader(req, 'Region', args.region, 'string');
|
||||||
|
|
||||||
|
|
@ -486,10 +548,11 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
|
||||||
this.checkParamHeader(req, 'Tab', args.tab_val, 'string');
|
this.checkParamHeader(req, 'Tab', args.tab_val, 'string');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that 'query' (no x-mcp-header) is NOT mirrored
|
// Check that 'query' (no x-mcp-header) is NOT mirrored — the negative
|
||||||
|
// half of "mirror the *designated* parameter values".
|
||||||
const queryHeader = req.headers['mcp-param-query'] as string | undefined;
|
const queryHeader = req.headers['mcp-param-query'] as string | undefined;
|
||||||
this.checks.push({
|
this.checks.push({
|
||||||
id: 'sep-2243-no-mirror-unannotated',
|
id: 'sep-2243-client-mirrors-designated-params',
|
||||||
name: 'ClientCustomHeaderNoMirrorUnannotated',
|
name: 'ClientCustomHeaderNoMirrorUnannotated',
|
||||||
description:
|
description:
|
||||||
'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header',
|
'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header',
|
||||||
|
|
@ -576,8 +639,20 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One check ID per SEP-2243 requirement, picked by what this parameter
|
||||||
|
// actually exercises: Base64 wrapping for unsafe values, primitive
|
||||||
|
// (number/boolean) string encoding, or plain mirroring. The per-parameter
|
||||||
|
// detail lives in `name`/`details`.
|
||||||
|
const needsBase64 =
|
||||||
|
typeof bodyValue === 'string' && needsBase64Encoding(String(bodyValue));
|
||||||
|
const checkId = needsBase64
|
||||||
|
? 'sep-2243-client-base64-unsafe'
|
||||||
|
: valueType === 'number' || valueType === 'boolean'
|
||||||
|
? 'sep-2243-client-encode-values'
|
||||||
|
: 'sep-2243-client-mirrors-designated-params';
|
||||||
|
|
||||||
this.checks.push({
|
this.checks.push({
|
||||||
id: `sep-2243-param-header-${headerName.toLowerCase()}`,
|
id: checkId,
|
||||||
name: `ClientCustomHeader_${headerName}`,
|
name: `ClientCustomHeader_${headerName}`,
|
||||||
description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`,
|
description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`,
|
||||||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||||
|
|
@ -589,9 +664,7 @@ export class HttpCustomHeadersScenario extends BaseHttpScenario {
|
||||||
rawHeaderValue,
|
rawHeaderValue,
|
||||||
bodyValue,
|
bodyValue,
|
||||||
valueType,
|
valueType,
|
||||||
needsBase64:
|
needsBase64
|
||||||
typeof bodyValue === 'string' &&
|
|
||||||
needsBase64Encoding(String(bodyValue))
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -637,24 +710,16 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario {
|
||||||
specReferences: [SPEC_REFERENCE_TOOL_DEF]
|
specReferences: [SPEC_REFERENCE_TOOL_DEF]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that the client did NOT call any of the invalid tools
|
// Check that the client did NOT call any of the invalid tools. Each
|
||||||
const invalidTools = [
|
// invalid tool violates one specific x-mcp-header constraint, so the
|
||||||
'invalid_empty_header',
|
// check is emitted under that constraint's requirement ID — the client
|
||||||
'invalid_object_header',
|
// rejecting the tool is how the constraint is enforced on the wire.
|
||||||
'invalid_array_header',
|
for (const [toolName, constraintId] of Object.entries(
|
||||||
'invalid_null_header',
|
INVALID_TOOL_CONSTRAINT_IDS
|
||||||
'invalid_duplicate_same_case',
|
)) {
|
||||||
'invalid_duplicate_diff_case',
|
|
||||||
'invalid_space_in_name',
|
|
||||||
'invalid_colon_in_name',
|
|
||||||
'invalid_non_ascii_name',
|
|
||||||
'invalid_control_char_name'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const toolName of invalidTools) {
|
|
||||||
const called = this.calledTools.has(toolName);
|
const called = this.calledTools.has(toolName);
|
||||||
this.checks.push({
|
this.checks.push({
|
||||||
id: 'sep-2243-client-reject-invalid-tool',
|
id: constraintId,
|
||||||
name: `ClientRejectsInvalidTool_${toolName}`,
|
name: `ClientRejectsInvalidTool_${toolName}`,
|
||||||
description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`,
|
description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`,
|
||||||
status: called ? 'FAILURE' : 'SUCCESS',
|
status: called ? 'FAILURE' : 'SUCCESS',
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,31 @@ const HEADER_MISMATCH_ERROR_CODE = -32001;
|
||||||
// Coarse, requirement-level check IDs (SEP-2243) for STANDARD-header
|
// Coarse, requirement-level check IDs (SEP-2243) for STANDARD-header
|
||||||
// rejections. Every standard-header rejection case emits this same pair of IDs;
|
// rejections. Every standard-header rejection case emits this same pair of IDs;
|
||||||
// the per-case name/description carry the detail of which case was exercised,
|
// the per-case name/description carry the detail of which case was exercised,
|
||||||
// matching the repo's "same id, vary status/message" convention. (Custom-header
|
// matching the repo's "same id, vary status/message" convention.
|
||||||
// /Base64 rejections map to different requirements and keep their own IDs.)
|
|
||||||
const REJECT_STATUS_CHECK_ID = 'sep-2243-server-reject-invalid-headers';
|
const REJECT_STATUS_CHECK_ID = 'sep-2243-server-reject-invalid-headers';
|
||||||
const REJECT_ERROR_CODE_CHECK_ID = 'sep-2243-server-reject-error-code';
|
const REJECT_ERROR_CODE_CHECK_ID = 'sep-2243-server-reject-error-code';
|
||||||
|
|
||||||
|
// CUSTOM-header (Mcp-Param-*) rejections map to the param-validation
|
||||||
|
// requirements instead. The -32001 error-code half of every custom-header
|
||||||
|
// rejection is the "reject with 400 + JSON-RPC error code -32001 if any
|
||||||
|
// validation fails" requirement.
|
||||||
|
const PARAM_REJECT_ERROR_CODE_CHECK_ID =
|
||||||
|
'sep-2243-server-reject-param-mismatch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every requirement-level check ID HttpCustomHeaderServerValidationScenario
|
||||||
|
* can emit (SEP-2243). When the server under test exposes no x-mcp-header
|
||||||
|
* tool the scenario cannot exercise these, but it still emits one SKIPPED row
|
||||||
|
* per ID so the traceability manifest sees that a scenario exists for each
|
||||||
|
* requirement (the manifest joins on emitted IDs regardless of status).
|
||||||
|
*/
|
||||||
|
export const CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS = [
|
||||||
|
'sep-2243-server-decode-base64',
|
||||||
|
'sep-2243-server-validate-param-match',
|
||||||
|
'sep-2243-server-reject-invalid-param-chars',
|
||||||
|
'sep-2243-server-reject-param-mismatch'
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to send a raw HTTP POST request with custom headers.
|
* Helper to send a raw HTTP POST request with custom headers.
|
||||||
* Uses Node.js http.request to preserve exact header casing and values,
|
* Uses Node.js http.request to preserve exact header casing and values,
|
||||||
|
|
@ -130,9 +150,10 @@ async function sendRawRequest(
|
||||||
*
|
*
|
||||||
* The two emitted check IDs are supplied explicitly by the caller: standard-
|
* The two emitted check IDs are supplied explicitly by the caller: standard-
|
||||||
* header callers pass the coarse REJECT_STATUS_CHECK_ID/REJECT_ERROR_CODE_CHECK_ID
|
* header callers pass the coarse REJECT_STATUS_CHECK_ID/REJECT_ERROR_CODE_CHECK_ID
|
||||||
* so all standard-header cases collapse onto one requirement, while custom-header
|
* pair, while custom-header/Base64 callers pass the param-validation
|
||||||
* /Base64 callers pass their own per-case ids. The per-case `name`/`description`
|
* requirement that case exercises plus PARAM_REJECT_ERROR_CODE_CHECK_ID. The
|
||||||
* distinguish which rejection case was exercised.
|
* per-case `name`/`description` distinguish which rejection case was
|
||||||
|
* exercised.
|
||||||
*/
|
*/
|
||||||
function createRejectionChecks(
|
function createRejectionChecks(
|
||||||
statusId: string,
|
statusId: string,
|
||||||
|
|
@ -300,7 +321,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-mismatched-method-header',
|
REJECT_STATUS_CHECK_ID,
|
||||||
'ServerRejectsMismatchedMethodHeader',
|
'ServerRejectsMismatchedMethodHeader',
|
||||||
'Server rejects requests where Mcp-Method header does not match body method',
|
'Server rejects requests where Mcp-Method header does not match body method',
|
||||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||||
|
|
@ -315,7 +336,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-missing-method-header',
|
REJECT_STATUS_CHECK_ID,
|
||||||
'ServerRejectsMissingMethodHeader',
|
'ServerRejectsMissingMethodHeader',
|
||||||
'Server rejects requests with missing Mcp-Method header',
|
'Server rejects requests with missing Mcp-Method header',
|
||||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||||
|
|
@ -333,7 +354,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-mismatched-name-header',
|
REJECT_STATUS_CHECK_ID,
|
||||||
'ServerRejectsMismatchedNameHeader',
|
'ServerRejectsMismatchedNameHeader',
|
||||||
'Server rejects tools/call where Mcp-Name does not match body params.name',
|
'Server rejects tools/call where Mcp-Name does not match body params.name',
|
||||||
{
|
{
|
||||||
|
|
@ -384,7 +405,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-missing-name-header',
|
REJECT_STATUS_CHECK_ID,
|
||||||
'ServerRejectsMissingNameHeader',
|
'ServerRejectsMissingNameHeader',
|
||||||
'Server MUST reject tools/call with missing Mcp-Name header when body has params.name',
|
'Server MUST reject tools/call with missing Mcp-Name header when body has params.name',
|
||||||
{
|
{
|
||||||
|
|
@ -442,7 +463,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-case-mismatch-value',
|
REJECT_STATUS_CHECK_ID,
|
||||||
'ServerRejectsCaseMismatchValue',
|
'ServerRejectsCaseMismatchValue',
|
||||||
'Server MUST reject uppercase method value (TOOLS/LIST) since values are case-sensitive',
|
'Server MUST reject uppercase method value (TOOLS/LIST) since values are case-sensitive',
|
||||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||||
|
|
@ -574,6 +595,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.'
|
'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.skipDeclaredChecks(
|
||||||
|
checks,
|
||||||
|
'Server exposes no tool with x-mcp-header annotations.'
|
||||||
|
);
|
||||||
return checks;
|
return checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,6 +657,10 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||||
});
|
});
|
||||||
|
this.skipDeclaredChecks(
|
||||||
|
checks,
|
||||||
|
'Server exposes no string-typed x-mcp-header parameter.'
|
||||||
|
);
|
||||||
return checks;
|
return checks;
|
||||||
}
|
}
|
||||||
const [paramName, paramDef] = annotatedEntry as [string, any];
|
const [paramName, paramDef] = annotatedEntry as [string, any];
|
||||||
|
|
@ -678,7 +707,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'accept',
|
'accept',
|
||||||
'sep-2243-server-accepts-valid-base64',
|
'sep-2243-server-decode-base64',
|
||||||
'ServerAcceptsValidBase64',
|
'ServerAcceptsValidBase64',
|
||||||
'Server decodes valid Base64 header value and validates against body',
|
'Server decodes valid Base64 header value and validates against body',
|
||||||
xMcpTool.name,
|
xMcpTool.name,
|
||||||
|
|
@ -702,7 +731,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-invalid-base64-padding',
|
'sep-2243-server-reject-invalid-param-chars',
|
||||||
'ServerRejectsInvalidBase64Padding',
|
'ServerRejectsInvalidBase64Padding',
|
||||||
'Server MUST reject Mcp-Param header with invalid Base64 padding (per SEP-2243 test-case table)',
|
'Server MUST reject Mcp-Param header with invalid Base64 padding (per SEP-2243 test-case table)',
|
||||||
xMcpTool.name,
|
xMcpTool.name,
|
||||||
|
|
@ -721,7 +750,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'reject',
|
'reject',
|
||||||
'sep-2243-server-rejects-invalid-base64-chars',
|
'sep-2243-server-reject-invalid-param-chars',
|
||||||
'ServerRejectsInvalidBase64Chars',
|
'ServerRejectsInvalidBase64Chars',
|
||||||
'Server MUST reject Mcp-Param header with non-alphabet Base64 characters (per SEP-2243 test-case table)',
|
'Server MUST reject Mcp-Param header with non-alphabet Base64 characters (per SEP-2243 test-case table)',
|
||||||
xMcpTool.name,
|
xMcpTool.name,
|
||||||
|
|
@ -740,7 +769,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'accept',
|
'accept',
|
||||||
'sep-2243-server-literal-missing-base64-prefix',
|
'sep-2243-server-validate-param-match',
|
||||||
'ServerLiteralMissingBase64Prefix',
|
'ServerLiteralMissingBase64Prefix',
|
||||||
'Server treats value without =?base64? prefix as literal (not Base64)',
|
'Server treats value without =?base64? prefix as literal (not Base64)',
|
||||||
xMcpTool.name,
|
xMcpTool.name,
|
||||||
|
|
@ -759,7 +788,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
baseHeaders,
|
baseHeaders,
|
||||||
nextId,
|
nextId,
|
||||||
'accept',
|
'accept',
|
||||||
'sep-2243-server-literal-missing-base64-suffix',
|
'sep-2243-server-validate-param-match',
|
||||||
'ServerLiteralMissingBase64Suffix',
|
'ServerLiteralMissingBase64Suffix',
|
||||||
'Server treats value without ?= suffix as literal (not Base64)',
|
'Server treats value without ?= suffix as literal (not Base64)',
|
||||||
xMcpTool.name,
|
xMcpTool.name,
|
||||||
|
|
@ -796,9 +825,47 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared-but-unemitted -> FAILURE. Reached only when setup threw partway
|
||||||
|
// through (the gate-out paths emit SKIPPED rows and the happy path emits
|
||||||
|
// every declared ID).
|
||||||
|
for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) {
|
||||||
|
if (checks.some((c) => c.id === id)) continue;
|
||||||
|
checks.push({
|
||||||
|
id,
|
||||||
|
name: 'NotObserved',
|
||||||
|
description: `Declared check ${id} was never emitted`,
|
||||||
|
status: 'FAILURE',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
errorMessage:
|
||||||
|
'Check was not observed: custom-header validation setup failed before this case ran.',
|
||||||
|
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return checks;
|
return checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit one SKIPPED row per declared requirement check when the scenario
|
||||||
|
* cannot run against this server (no x-mcp-header tool). The IDs must still
|
||||||
|
* reach checks.json so the traceability manifest records that a scenario
|
||||||
|
* exists for each requirement.
|
||||||
|
*/
|
||||||
|
private skipDeclaredChecks(checks: ConformanceCheck[], reason: string): void {
|
||||||
|
for (const id of CUSTOM_HEADER_SERVER_DECLARED_CHECK_IDS) {
|
||||||
|
if (checks.some((c) => c.id === id)) continue;
|
||||||
|
checks.push({
|
||||||
|
id,
|
||||||
|
name: 'NotApplicable',
|
||||||
|
description: `Declared check ${id} is not testable against this server`,
|
||||||
|
status: 'SKIPPED',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
specReferences: [SPEC_REFERENCE_CUSTOM],
|
||||||
|
details: { reason }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async testBase64Case(
|
private async testBase64Case(
|
||||||
checks: ConformanceCheck[],
|
checks: ConformanceCheck[],
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
|
|
@ -858,12 +925,14 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Custom-header rejection: both 400 and -32001 are MUST per
|
// Custom-header rejection: both 400 and -32001 are MUST per
|
||||||
// §Server Behavior for Custom Headers. These map to param-validation
|
// §Server Behavior for Custom Headers. The status half maps to the
|
||||||
// requirements, so they keep their per-case ids (status + -error-code).
|
// param-validation requirement this case exercises (checkId); the
|
||||||
|
// error-code half is the "400 + -32001 if any validation fails"
|
||||||
|
// requirement.
|
||||||
checks.push(
|
checks.push(
|
||||||
...createRejectionChecks(
|
...createRejectionChecks(
|
||||||
checkId,
|
checkId,
|
||||||
`${checkId}-error-code`,
|
PARAM_REJECT_ERROR_CODE_CHECK_ID,
|
||||||
checkName,
|
checkName,
|
||||||
description,
|
description,
|
||||||
response,
|
response,
|
||||||
|
|
@ -919,12 +988,13 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Custom-header rejection: both 400 and -32001 are MUST. Keeps its own
|
// Custom-header rejection: both 400 and -32001 are MUST. A header
|
||||||
// per-case ids (status + -error-code).
|
// omitted while the body carries a value is a header/body mismatch, so
|
||||||
|
// the status half maps to the validate-param-match requirement.
|
||||||
checks.push(
|
checks.push(
|
||||||
...createRejectionChecks(
|
...createRejectionChecks(
|
||||||
'sep-2243-server-rejects-missing-custom-header',
|
'sep-2243-server-validate-param-match',
|
||||||
'sep-2243-server-rejects-missing-custom-header-error-code',
|
PARAM_REJECT_ERROR_CODE_CHECK_ID,
|
||||||
'ServerRejectsMissingCustomHeader',
|
'ServerRejectsMissingCustomHeader',
|
||||||
'Server MUST reject request where custom header is omitted but value is present in body',
|
'Server MUST reject request where custom header is omitted but value is present in body',
|
||||||
response,
|
response,
|
||||||
|
|
@ -941,7 +1011,7 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
checks.push({
|
checks.push({
|
||||||
id: 'sep-2243-server-rejects-missing-custom-header',
|
id: 'sep-2243-server-validate-param-match',
|
||||||
name: 'ServerRejectsMissingCustomHeader',
|
name: 'ServerRejectsMissingCustomHeader',
|
||||||
description:
|
description:
|
||||||
'Server MUST reject request where custom header is omitted but value is present in body',
|
'Server MUST reject request where custom header is omitted but value is present in body',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue