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:
Paul Carleton 2026-05-22 23:37:25 +01:00 • committed by GitHub
commit 909ea0b5fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 399 additions and 64 deletions

View 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();
}
});
});

View file

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

View file

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