fix(sep-2243): collapse check IDs to one-per-requirement (#287)

The sep-2243 scenario check IDs drifted from the requirement-traceability
yaml in #259 — the code emitted one ID per test case while the yaml
declares one per normative requirement, so the IDs no longer matched.

Rename the emitted IDs so a check ID maps to a single MUST/SHOULD and is
emitted once per case (the per-case detail moves to name/description),
matching the repo's existing 'same id, vary status/message' convention:

- mcp-method-header-* / mcp-name-header-*  -> client-includes-standard-headers
- reject-invalid-tool-* / keep-valid-tool  -> client-reject-invalid-tool
- server-accepts-{lower,upper}case-name    -> header-name-case-insensitive
- server reject status checks (mismatch/missing/case x method/name)
                                            -> server-reject-invalid-headers
- their error-code variants                 -> server-reject-error-code

Merge the yaml's server-reject-mismatch + server-reject-status into one
server-reject-invalid-headers MUST (HTTP 400 on a header-validation
failure); keep server-reject-error-code as the SHOULD. Base64/custom-header
rejection checks keep their own param-validation IDs (out of scope here).

Gates and the whitespace-acceptance check keep their scenario-matching
names. No behavior change — only check IDs, descriptions, and the yaml.
This commit is contained in:
Paul Carleton 2026-05-19 21:48:31 +01:00 • committed by GitHub
commit 70f7ba0004
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 48 additions and 23 deletions

View file

@ -626,7 +626,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario {
// Check that valid_tool WAS called — proves client kept valid tools // Check that valid_tool WAS called — proves client kept valid tools
const validToolCalled = this.calledTools.has('valid_tool'); const validToolCalled = this.calledTools.has('valid_tool');
this.checks.push({ this.checks.push({
id: 'sep-2243-keep-valid-tool', id: 'sep-2243-client-reject-invalid-tool',
name: 'ClientKeepsValidTool', name: 'ClientKeepsValidTool',
description: 'Client MUST keep valid tools while excluding invalid ones', description: 'Client MUST keep valid tools while excluding invalid ones',
status: validToolCalled ? 'SUCCESS' : 'FAILURE', status: validToolCalled ? 'SUCCESS' : 'FAILURE',
@ -654,7 +654,7 @@ export class HttpInvalidToolHeadersScenario extends BaseHttpScenario {
for (const toolName of invalidTools) { 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-reject-invalid-tool-${toolName.replace(/_/g, '-')}`, id: 'sep-2243-client-reject-invalid-tool',
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

@ -31,14 +31,19 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => {
}); });
} }
it('FAILs sep-2243-mcp-method-header-initialize when Mcp-Method is missing', async () => { // The coarse check id is emitted once per method/name case, so we narrow to
// the initialize Mcp-Method emission via its (case-specific) name.
const COARSE_ID = 'sep-2243-client-includes-standard-headers';
const INIT_METHOD_NAME = 'ClientMcpMethodHeader_initialize';
it('FAILs the initialize Mcp-Method emission when Mcp-Method is missing', async () => {
const scenario = new HttpStandardHeadersScenario(); const scenario = new HttpStandardHeadersScenario();
const { serverUrl } = await scenario.start(); const { serverUrl } = await scenario.start();
try { try {
await postInitialize(serverUrl, {}); // no Mcp-Method header await postInitialize(serverUrl, {}); // no Mcp-Method header
const checks = scenario.getChecks(); const checks = scenario.getChecks();
const check = checks.find( const check = checks.find(
(c) => c.id === 'sep-2243-mcp-method-header-initialize' (c) => c.id === COARSE_ID && c.name === INIT_METHOD_NAME
); );
expect(check?.status).toBe('FAILURE'); expect(check?.status).toBe('FAILURE');
} finally { } finally {
@ -46,14 +51,14 @@ describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => {
} }
}); });
it('SUCCEEDs sep-2243-mcp-method-header-initialize when Mcp-Method matches', async () => { it('SUCCEEDs the initialize Mcp-Method emission when Mcp-Method matches', async () => {
const scenario = new HttpStandardHeadersScenario(); const scenario = new HttpStandardHeadersScenario();
const { serverUrl } = await scenario.start(); const { serverUrl } = await scenario.start();
try { try {
await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' }); await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' });
const checks = scenario.getChecks(); const checks = scenario.getChecks();
const check = checks.find( const check = checks.find(
(c) => c.id === 'sep-2243-mcp-method-header-initialize' (c) => c.id === COARSE_ID && c.name === INIT_METHOD_NAME
); );
expect(check?.status).toBe('SUCCESS'); expect(check?.status).toBe('SUCCESS');
} finally { } finally {

View file

@ -53,7 +53,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario {
for (const method of expectedMethods) { for (const method of expectedMethods) {
if (!this.methodHeaderChecks.has(method)) { if (!this.methodHeaderChecks.has(method)) {
result.push({ result.push({
id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`, id: 'sep-2243-client-includes-standard-headers',
name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Method header on ${method} request`, description: `Client sends correct Mcp-Method header on ${method} request`,
status: 'SKIPPED', status: 'SKIPPED',
@ -68,7 +68,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario {
for (const method of expectedNameMethods) { for (const method of expectedNameMethods) {
if (!this.nameHeaderChecks.has(method)) { if (!this.nameHeaderChecks.has(method)) {
result.push({ result.push({
id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`, id: 'sep-2243-client-includes-standard-headers',
name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Name header on ${method} request`, description: `Client sends correct Mcp-Name header on ${method} request`,
status: 'SKIPPED', status: 'SKIPPED',
@ -141,7 +141,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario {
this.methodHeaderChecks.set(method, errors.length === 0); this.methodHeaderChecks.set(method, errors.length === 0);
this.checks.push({ this.checks.push({
id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`, id: 'sep-2243-client-includes-standard-headers',
name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`, name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Method header on ${method} request`, description: `Client sends correct Mcp-Method header on ${method} request`,
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
@ -186,7 +186,7 @@ export class HttpStandardHeadersScenario extends BaseHttpScenario {
this.nameHeaderChecks.set(method, errors.length === 0); this.nameHeaderChecks.set(method, errors.length === 0);
this.checks.push({ this.checks.push({
id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`, id: 'sep-2243-client-includes-standard-headers',
name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`, name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Name header on ${method} request`, description: `Client sends correct Mcp-Name header on ${method} request`,
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE', status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',

View file

@ -53,6 +53,14 @@ const SPEC_REFERENCE_CUSTOM = {
const HEADER_MISMATCH_ERROR_CODE = -32001; const HEADER_MISMATCH_ERROR_CODE = -32001;
// Coarse, requirement-level check IDs (SEP-2243) for STANDARD-header
// 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,
// matching the repo's "same id, vary status/message" convention. (Custom-header
// /Base64 rejections map to different requirements and keep their own IDs.)
const REJECT_STATUS_CHECK_ID = 'sep-2243-server-reject-invalid-headers';
const REJECT_ERROR_CODE_CHECK_ID = 'sep-2243-server-reject-error-code';
/** /**
* 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,
@ -119,9 +127,16 @@ async function sendRawRequest(
* but -32001 is SHOULD for *standard* headers (and MUST for *custom* headers, * but -32001 is SHOULD for *standard* headers (and MUST for *custom* headers,
* §Server Behavior for Custom Headers) — so a server returning 400 with a * §Server Behavior for Custom Headers) — so a server returning 400 with a
* different error code is compliant for standard headers and must not FAIL. * different error code is compliant for standard headers and must not FAIL.
*
* 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
* so all standard-header cases collapse onto one requirement, while custom-header
* /Base64 callers pass their own per-case ids. The per-case `name`/`description`
* distinguish which rejection case was exercised.
*/ */
function createRejectionChecks( function createRejectionChecks(
id: string, statusId: string,
errorCodeId: string,
name: string, name: string,
description: string, description: string,
response: { status: number; body: any }, response: { status: number; body: any },
@ -141,7 +156,7 @@ function createRejectionChecks(
return [ return [
{ {
id, id: statusId,
name, name,
description, description,
status: statusOk ? 'SUCCESS' : 'FAILURE', status: statusOk ? 'SUCCESS' : 'FAILURE',
@ -153,7 +168,7 @@ function createRejectionChecks(
details: fullDetails details: fullDetails
}, },
{ {
id: `${id}-error-code`, id: errorCodeId,
name: `${name}ErrorCode`, name: `${name}ErrorCode`,
description: `${description} — uses JSON-RPC error code -32001 (HeaderMismatch)`, description: `${description} — uses JSON-RPC error code -32001 (HeaderMismatch)`,
status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity, status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity,
@ -397,7 +412,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
baseHeaders, baseHeaders,
nextId, nextId,
'accept', 'accept',
'sep-2243-server-accepts-lowercase-header-name', 'sep-2243-header-name-case-insensitive',
'ServerAcceptsLowercaseHeaderName', 'ServerAcceptsLowercaseHeaderName',
'Server MUST accept lowercase header name (mcp-method)', 'Server MUST accept lowercase header name (mcp-method)',
{ jsonrpc: '2.0', id: 0, method: 'tools/list' }, { jsonrpc: '2.0', id: 0, method: 'tools/list' },
@ -412,7 +427,7 @@ export class HttpHeaderValidationScenario implements ClientScenario {
baseHeaders, baseHeaders,
nextId, nextId,
'accept', 'accept',
'sep-2243-server-accepts-uppercase-header-name', 'sep-2243-header-name-case-insensitive',
'ServerAcceptsUppercaseHeaderName', 'ServerAcceptsUppercaseHeaderName',
'Server MUST accept uppercase header name (MCP-METHOD)', 'Server MUST accept uppercase header name (MCP-METHOD)',
{ jsonrpc: '2.0', id: 0, method: 'tools/list' }, { jsonrpc: '2.0', id: 0, method: 'tools/list' },
@ -471,10 +486,13 @@ export class HttpHeaderValidationScenario implements ClientScenario {
...extraHeaders ...extraHeaders
}); });
if (expectation === 'reject') { if (expectation === 'reject') {
// Standard-header rejection: 400 is MUST, -32001 is SHOULD. // Standard-header rejection: 400 is MUST, -32001 is SHOULD. All
// standard-header rejection cases collapse onto the coarse requirement
// ids; checkId/checkName still distinguish the case via name/details.
checks.push( checks.push(
...createRejectionChecks( ...createRejectionChecks(
checkId, REJECT_STATUS_CHECK_ID,
REJECT_ERROR_CODE_CHECK_ID,
checkName, checkName,
description, description,
response, response,
@ -840,10 +858,12 @@ 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. // §Server Behavior for Custom Headers. These map to param-validation
// requirements, so they keep their per-case ids (status + -error-code).
checks.push( checks.push(
...createRejectionChecks( ...createRejectionChecks(
checkId, checkId,
`${checkId}-error-code`,
checkName, checkName,
description, description,
response, response,
@ -899,10 +919,12 @@ export class HttpCustomHeaderServerValidationScenario implements ClientScenario
} }
); );
// Custom-header rejection: both 400 and -32001 are MUST. // Custom-header rejection: both 400 and -32001 are MUST. Keeps its own
// per-case ids (status + -error-code).
checks.push( checks.push(
...createRejectionChecks( ...createRejectionChecks(
'sep-2243-server-rejects-missing-custom-header', 'sep-2243-server-rejects-missing-custom-header',
'sep-2243-server-rejects-missing-custom-header-error-code',
'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,

View file

@ -5,10 +5,8 @@ requirements:
text: 'The client MUST include the standard MCP request headers on each POST request. These headers are REQUIRED for compliance.' text: 'The client MUST include the standard MCP request headers on each POST request. These headers are REQUIRED for compliance.'
- check: sep-2243-header-name-case-insensitive - check: sep-2243-header-name-case-insensitive
text: 'Clients and servers MUST use case-insensitive comparisons for header names.' text: 'Clients and servers MUST use case-insensitive comparisons for header names.'
- check: sep-2243-server-reject-mismatch - check: sep-2243-server-reject-invalid-headers
text: 'Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body.' text: 'Servers that process the request body MUST reject requests with mismatched or missing standard-header values, returning HTTP 400 Bad Request.'
- check: sep-2243-server-reject-status
text: 'When rejecting a request due to header validation failure, servers MUST return HTTP status 400 Bad Request.'
- check: sep-2243-server-reject-error-code - check: sep-2243-server-reject-error-code
text: 'When rejecting a request due to header validation failure, servers SHOULD include a JSON-RPC error response using error code -32001.' text: 'When rejecting a request due to header validation failure, servers SHOULD include a JSON-RPC error response using error code -32001.'
- check: sep-2243-client-supports-custom-headers - check: sep-2243-client-supports-custom-headers