Add conformance tests for SEP-2243 HTTP Standardization (#259)

* Add prepare script for git dependency installs

* Add conformance tests for SEP-2243 HTTP Standardization

* Add traceability file for SEP-2243

* Fix prettier formatting in http-standard-headers.ts

* Improve SEP-2243 conformance test coverage

Close gaps in HTTP header conformance scenarios:

Client standard headers (http-standard-headers.ts):
- Enforce all expected methods in checks, failing any that were not observed
- Track Mcp-Name header per-method separately from Mcp-Method
- Advertise resources and prompts capabilities so clients exercise those endpoints
- Add a prompt entry for prompts/get testing

Client custom headers (http-custom-headers.ts):
- Add Base64 encoding checks for non-ASCII, whitespace, and control-char values
- Add null/omitted parameter test (second tool call with null value)
- Add client-keeps-valid-tool check verifying clients still call valid tools
  after filtering out invalid ones

Server header validation (server/http-standard-headers.ts):
- Replace fetch-based sendRawRequest with http.request to preserve exact
  header casing on the wire (fetch/Headers lowercases names)
- Compute defaultArgs and defaultHeaders from tool schema so test requests
  satisfy all required parameters while varying only the param under test

Traceability (sep-2243.yaml):
- Add 7 new spec-to-check mappings (nonascii, whitespace, controlchar,
  keeps-valid-tool, literal-missing-base64-prefix/suffix, no-mirror-unannotated)
- Fix 2 incorrect existing mappings

* Fill SEP-2243 conformance test coverage gaps

Add missing test cases identified by comparing the branch against the
SEP-2243 spec's Conformance Test Cases section:

- Mcp-Method on notifications: add notifications/initialized to
  expectedMethods so clients are checked for header on notifications
- Boolean true: add debug=true param to complement verbose=false
- Leading-space-only and trailing-space-only: separate checks for
  Base64 encoding when only one edge has whitespace
- Internal spaces only: verify plain ASCII (no Base64) for values
  like 'us west 1' that have spaces only in the middle
- CRLF string: add line1\r\nline2 test (complements existing \n test)
- Leading tab: add \tindented test for tab-triggered Base64 encoding
- Server rejects invalid Mcp-Param chars: mark as excluded in YAML
  since HTTP itself prevents these characters in headers

Register all new checks in sep-2243.yaml.

* Remove case-insensitive =?base64? prefix test

The spec states header values are case-sensitive (RFC 9110). The
=?base64? prefix is part of the header value, not the header name,
so it should be matched case-sensitively. This was identified as a
bug in the conformance tests per feedback on SEP-2243.

Changes:
- Remove server-accepts-case-insensitive-base64 check from server
  validation scenario
- Change base64 prefix regex from case-insensitive (/i) to
  case-sensitive in client header validation
- Remove corresponding entry from sep-2243.yaml

* Remove nested x-mcp-header rejection test

The spec constrains x-mcp-header to primitive types but does not
restrict it to top-level properties only. A nested string property
with x-mcp-header is valid per the spec. This was confirmed by
mikekistler in SEP-2243 PR discussion.

Removes invalid_nested_header from the invalid tool definitions
list and its corresponding sep-2243.yaml entry.

* Use numeric comparison for number header values

Compare number header values numerically instead of as strings to
accommodate cross-SDK floating point representation differences
(e.g., '42' vs '42.0'). For integers, exact numeric match is
required. For decimals, a tolerance of 1e-9 is allowed.

See SEP-2243 discussion on number precision.

* client/http-standard-headers: SKIPPED (not FAILURE) for unexercised methods; make getChecks() idempotent

A client that never calls prompts/list isn't violating SEP-2243 — the spec
sentence is 'The client MUST include the standard MCP request headers on each
POST request', and a request that was never sent can't fail it. The Mcp-Method
requirement is already proven by initialize/tools/list etc., so there's nothing
unique to prompts/list. SKIPPED keeps the gap visible without a false red.

Separately: getChecks() was pushing into this.checks on every call. The runner
may call it more than once (e.g. progress + final report); that produced
duplicate rows. Build a fresh array per call instead.

* client/http-standard-headers: de-dup guard in checkMcpNameHeader

checkMcpMethodHeader already has 'if (this.methodHeaderChecks.has(method)) return'.
checkMcpNameHeader was missing the equivalent. The test server advertises two
tools and two resources; a client that calls both produces two
'client-mcp-name-header-tools-call' / '...-resources-read' rows.

* client/http-standard-headers: replace -> /\//g for slug generation

String.replace with a string arg only replaces the first occurrence.
'notifications/initialized' is fine (one slash) but a method like
'notifications/resources/updated' would yield 'notifications-resources/updated'
in the check id. (replaceAll isn't in this project's TS lib target.)

* client/http-custom-headers: base64 regex (.+) -> (.*) so empty string round-trips

The spec doesn't forbid base64-encoding values that don't strictly need it.
A client that always wraps would send '=?base64??=' for empty_val: ''. The
'.+' wouldn't match, so the harness would compare '=?base64??=' === ''
literally and FAIL a compliant client. '(.*)' lets the empty payload decode
to '' and match.

* client/http-custom-headers: emit SUCCESS for no-mirror-unannotated

This check only pushed on FAILURE; a correct client produced no row at all,
so the pass was invisible in reports and couldn't be counted toward coverage.
Same-slug SUCCESS/FAILURE pair is the repo convention.

* client/http-custom-headers: drop redundant optional-present check

checkParamHeader('Verbose', ...) already pushes a FAILURE when the header is
missing. The explicit 'optional-present' block re-tests the same condition
under a second check id, doubling the row.

* server/http-standard-headers: defaultArgs must use real number/boolean, not strings

defaultArgs feeds the JSON-RPC body. With Record<string, string> + '0'/'false',
a server that validates inputSchema rejects on type mismatch — which is also a
400. Every header-rejection check then false-PASSes (server rejected for the
wrong reason), and server-accepts-valid-base64 false-FAILs a compliant server
because it never gets past schema validation.

Also handle 'integer' (JSON Schema distinguishes it from 'number').

* server/http-standard-headers: createAcceptanceCheck must also assert no JSON-RPC error in body

A server can return HTTP 200 with {error: {code: -32001, ...}} in the body.
Status-only acceptance lets that through as a pass.

* use DRAFT_PROTOCOL_VERSION constant instead of 'DRAFT-2026-v1' literal

The literal will rot when the draft cycle rolls over. types.ts already exports
DRAFT_PROTOCOL_VERSION for this.

* sep-2243.yaml: spec-first rewrite + move to src/seps/

Regenerated from the SEP-2243 spec diff (transports.mdx + tools.mdx) rather
than from the scenario implementations: 21 check rows + 4 excluded vs the
previous 46 + 2. Differences:

- one check id per spec sentence (test variants go in 'details', not new ids)
- 'text:' quotes the spec sentence verbatim instead of paraphrasing
- '-reject-status' (MUST 400) split from '-reject-error-code' (SHOULD -32001
  for standard headers, MUST for custom)
- rows with no spec backing dropped (whitespace, base64-padding/chars, prefix/
  suffix-literal, control-char-name)
- two more SHOULD excludes (log-warning, no-sensitive-params)
- check ids use sep-2243-<slug> convention
- check: key first, excludes grouped at bottom (matches sep-2164)

Moved to src/seps/ to match #272 layout (this branch predates it; will
reconcile cleanly on rebase).

* add negative test for HttpStandardHeadersScenario

Self-contained vitest that POSTs initialize with and without Mcp-Method,
asserts FAILURE/SUCCESS on the pinned check id, and proves getChecks() is
idempotent. No example client/server file needed for this one because the
scenario *is* the server — the test crafts the bad request directly.

This is the negative-test half of AGENTS.md §Testing your scenario; the
everything-client passing-example half is still TODO.

* server/http-standard-headers: severity fixes for whitespace + malformed-base64 tests

These five tests assert behavior the SEP-2243 text doesn't pin down. Kept
because they're useful consistency signals across SDKs, but adjusted so they
don't FAIL spec-compliant servers:

- server-accepts-whitespace-header-value: this IS a MUST, but per RFC 9110
  §5.5 (field parsing MUST exclude OWS), not SEP-2243. Kept as FAILURE,
  specReference now cites RFC 9110.

- server-rejects-invalid-base64-padding / -chars: downgraded to INFO.
  SEP-2243 says only 'MUST decode them accordingly' without picking RFC 4648
  strict vs lenient. Node's Buffer.from() accepts unpadded/dirty input and
  decodes to a matching value (server accepts); .NET's Convert.FromBase64String
  throws (server rejects). Either is currently compliant. WARNING would force
  Tier-1 SDKs into non-default strict decoding (#245); INFO records the
  behavior without prescribing it, so cross-SDK divergence is visible
  without tier impact.

- server-literal-missing-base64-prefix / -suffix: unchanged. The wrapper
  syntax is '=?base64?{x}?=' complete; treating partial wrappers as literal
  is the natural reading.

Plumbing: createRejectionCheck gains an optional failSeverity param;
testBase64Case gains a 'reject-info' mode. (This is also the hook for the
larger 400-MUST / -32001-SHOULD split that still needs doing.)

* server/http-standard-headers: split rejection check into status (MUST) + error-code (SHOULD/MUST)

SEP-2243 §Server Validation: 'servers MUST return HTTP status 400 ... and
SHOULD include a JSON-RPC error response using ... -32001'. So a server that
returns 400 with -32600 (or no error body) is compliant for standard headers.
The single conflated check FAILed it.

For custom headers (§Server Behavior for Custom Headers) -32001 IS a MUST,
so both halves stay FAILURE there.

createRejectionCheck → createRejectionChecks returning two rows: '<id>' for
the 400 status and '<id>-error-code' for -32001. testCase (standard) sets
errorCodeSeverity=WARNING; testBase64Case/testMissingCustomHeader (custom)
keep FAILURE; the two malformed-base64 INFO probes set both halves to INFO.

* rename check ids to sep-2243-* prefix; kebab-case throughout

Aligns with the sep-2243.yaml traceability ids and the repo convention
(sep-2164-*, sep-2207-*). Variant suffixes are kept (per-method, per-tool)
so per-case visibility isn't lost; the YAML's row id is a prefix of the
emitted ids, which is fine — extra ids not in the YAML are allowed.

Also fixes the mixed hyphen/underscore in reject-invalid-tool ids
(toolName has underscores; replace to hyphens).

* client: extract BaseHttpScenario to client/http-base.ts; both client scenarios extend it

http-standard-headers.ts had its own copy of the start/stop/handleRequest
boilerplate (~100 lines) that http-custom-headers.ts already abstracted as
BaseHttpScenario. Moved the abstract class to a shared file; both scenario
files now import it. HttpStandardHeadersScenario implements handlePost()
instead of handleRequest(); its handleInitialize() uses the base
sendInitialize() with the resources/prompts capability flags it needs.

Net: 465 → 361 lines for http-standard-headers.ts; the third inline copy of
the same HTTP-server scaffold is gone.

* use req.setEncoding('utf8') instead of per-chunk Buffer.toString()

Per-chunk .toString() corrupts a multi-byte UTF-8 char that straddles a TCP
chunk boundary (e.g. 0xC3 | 0xBC decodes to two replacement chars instead of
'ü'). The custom-headers scenario sends '日本語'/'naïve' in the body, so this
is reachable in principle. setEncoding('utf8') makes 'data' emit strings with
boundary handling done by Node's StringDecoder.

Fixed in both places: BaseHttpScenario.handleRequest (request body) and
sendRawRequest (response body).

* fix: migrate SEP-2243 scenarios from specVersions to source (introducedIn/removedIn)

The introducedIn/removedIn refactor (#265) replaced the specVersions array
with a source property on all scenario interfaces. The SEP-2243 scenarios
still used the old specVersions field, causing matchesSpecVersion() to
receive undefined and crash in spec-version.test.ts.

Replace specVersions with source = { introducedIn: DRAFT_PROTOCOL_VERSION }
in BaseHttpScenario and the two server ClientScenario classes. Remove the
now-unused SpecVersion imports.

* server/http-standard-headers: malformed-base64 padding/chars back to FAILURE

Per discussion on #259: the SEP-2243 conformance-test-case table is the
approved source of truth and lists these as reject cases, so they're
FAILURE-level even though the spec body itself only says 'MUST decode them
accordingly'. SDKs whose stdlib base64 is lenient (Node, browsers) will need
to validate before decoding; if that's burdensome we'll revisit.

Removes the now-unused 'reject-info' mode and statusSeverity/INFO plumbing
introduced for these two probes.

---------

Co-authored-by: Tarek Mahmoud Sayed <tarekms@microsoft.com>
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
Co-authored-by: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com>
Co-authored-by: Paul Carleton <paulc@anthropic.com>
This commit is contained in:
Mike Kistler 2026-05-15 07:20:43 -05:00 • committed by GitHub
commit 39f34b2c58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,879 @@
/**
* HTTP Custom Headers conformance test scenario for MCP clients (SEP-2243)
*
* Tests that clients correctly handle the `x-mcp-header` extension property:
* 1. Mirror annotated tool parameter values into `Mcp-Param-{Name}` headers
* 2. Apply correct value encoding (plain ASCII, Base64 for non-ASCII)
* 3. Reject tool definitions with invalid `x-mcp-header` annotations
*
* This is a Scenario (acts as a test server that inspects incoming requests
* from the client under test).
*/
import http from 'http';
import { ScenarioUrls, ConformanceCheck } from '../../types.js';
import { BaseHttpScenario } from './http-base.js';
const SPEC_REFERENCE_CUSTOM = {
id: 'SEP-2243-Custom-Headers',
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#custom-headers-from-tool-parameters'
};
const SPEC_REFERENCE_ENCODING = {
id: 'SEP-2243-Value-Encoding',
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding'
};
const SPEC_REFERENCE_TOOL_DEF = {
id: 'SEP-2243-x-mcp-header',
url: 'https://modelcontextprotocol.io/specification/draft/server/tools#x-mcp-header'
};
/**
* Decodes a header value that may be Base64-encoded.
* Base64-encoded values use the format: =?base64?{Base64EncodedValue}?=
*/
function decodeHeaderValue(value: string): string {
const base64Match = value.match(/^=\?base64\?(.*)\?=$/);
if (base64Match) {
return Buffer.from(base64Match[1], 'base64').toString('utf-8');
}
return value;
}
/**
* Check if a value needs Base64 encoding per the spec:
* - Non-ASCII characters
* - Control characters
* - Leading/trailing whitespace
*/
function needsBase64Encoding(value: string): boolean {
// Check for non-ASCII or control characters
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code < 0x20 || code > 0x7e) {
// Allow space (0x20) and tab (0x09) only inside values, not at edges
if (code === 0x09) return true; // tab always needs encoding
if (code < 0x20) return true; // other control chars
if (code > 0x7e) return true; // non-ASCII
}
}
// Check for leading/trailing whitespace
if (value !== value.trim()) return true;
return false;
}
/**
* Checks if a raw header value is properly encoded for a body value that
* needs Base64 encoding. Returns null if valid, error string if invalid.
*/
function validateEncodedHeader(
rawHeader: string,
bodyValue: string,
valueType?: string
): string | null {
if (needsBase64Encoding(bodyValue)) {
// Value requires Base64 encoding
const base64Match = rawHeader.match(/^=\?base64\?(.*)\?=$/);
if (!base64Match) {
return `Value '${bodyValue}' requires Base64 encoding but header was sent as plain: '${rawHeader}'`;
}
const decoded = Buffer.from(base64Match[1], 'base64').toString('utf-8');
if (valueType === 'number') {
return compareNumericValues(decoded, bodyValue);
}
if (decoded !== bodyValue) {
return `Base64-decoded header value '${decoded}' does not match body value '${bodyValue}'`;
}
return null;
}
// Plain ASCII - compare directly (after decoding if Base64 was used)
const decoded = decodeHeaderValue(rawHeader);
if (valueType === 'number') {
return compareNumericValues(decoded, bodyValue);
}
if (decoded !== bodyValue) {
return `Header value '${decoded}' (raw: '${rawHeader}') does not match body value '${bodyValue}'`;
}
return null;
}
/**
* Compare two string representations of numbers numerically.
* For integers, requires exact match. For decimals, allows
* a tolerance of 1e-9 to account for cross-SDK floating point
* representation differences.
*/
function compareNumericValues(
headerValue: string,
bodyValue: string
): string | null {
const headerNum = Number(headerValue);
const bodyNum = Number(bodyValue);
if (isNaN(headerNum) || isNaN(bodyNum)) {
return `Non-numeric value in number comparison: header='${headerValue}', body='${bodyValue}'`;
}
if (Number.isInteger(bodyNum)) {
// Integer: require exact numeric match (e.g. 42 === 42.0)
if (headerNum !== bodyNum) {
return `Numeric header value ${headerNum} does not match body value ${bodyNum}`;
}
} else {
// Decimal: allow tolerance of 1e-9
if (Math.abs(headerNum - bodyNum) > 1e-9) {
return `Numeric header value ${headerNum} does not match body value ${bodyNum} (difference ${Math.abs(headerNum - bodyNum)} exceeds tolerance 1e-9)`;
}
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────────
// HttpCustomHeadersScenario - tests that clients mirror x-mcp-header params
// ─────────────────────────────────────────────────────────────────────────────
export class HttpCustomHeadersScenario extends BaseHttpScenario {
name = 'http-custom-headers';
description =
'Tests that client mirrors x-mcp-header tool parameters into Mcp-Param headers with correct encoding (SEP-2243)';
private toolCallReceived: boolean = false;
private nullToolCallReceived: boolean = false;
async start(): Promise<ScenarioUrls> {
const urls = await super.start();
// Pass test values via context for encoding edge cases.
// The conformance client should use these values when calling test_custom_headers.
urls.context = {
toolCalls: [
{
name: 'test_custom_headers',
arguments: {
region: 'us-west1',
priority: 42,
verbose: false,
debug: true,
empty_val: '',
method_val: 'test-method',
float_val: 3.14159,
non_ascii_val: 'Hello, 世界',
whitespace_val: ' padded ',
leading_space_val: ' us-west1',
trailing_space_val: 'us-west1 ',
internal_space_val: 'us west 1',
control_char_val: 'line1\nline2',
crlf_val: 'line1\r\nline2',
tab_val: '\tindented',
query: 'SELECT * FROM users'
}
},
{
name: 'test_custom_headers_null',
arguments: {
region: 'us-east1',
priority: 1,
verbose: null,
query: 'SELECT 1'
}
}
]
};
return urls;
}
getChecks(): ConformanceCheck[] {
if (!this.toolCallReceived) {
this.checks.push({
id: 'sep-2243-param-header-tool-call-gate',
name: 'ClientCustomHeaderToolCall',
description: 'Client calls the tool with x-mcp-header annotations',
status: 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage:
'Client did not send a tools/call request for test_custom_headers.',
specReferences: [SPEC_REFERENCE_CUSTOM]
});
}
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]
});
}
return this.checks;
}
protected handlePost(
req: http.IncomingMessage,
res: http.ServerResponse,
request: any
): void {
if (request.method === 'initialize') {
this.sendInitialize(res, request);
} else if (request.method === 'tools/list') {
this.handleToolsList(res, request);
} else if (request.method === 'tools/call') {
this.handleToolsCall(req, res, request);
} else if (request.id === undefined) {
this.sendNotificationAck(res);
} else {
this.sendGenericResult(res, request);
}
}
private handleToolsList(res: http.ServerResponse, request: any): void {
this.sendJson(res, {
jsonrpc: '2.0',
id: request.id,
result: {
tools: [
{
name: 'test_custom_headers',
description:
'A tool with x-mcp-header annotations to test custom header mirroring and encoding',
inputSchema: {
type: 'object',
properties: {
region: {
type: 'string',
description: 'Plain ASCII string value',
'x-mcp-header': 'Region'
},
priority: {
type: 'number',
description: 'Integer numeric value',
'x-mcp-header': 'Priority'
},
verbose: {
type: 'boolean',
description: 'Boolean value',
'x-mcp-header': 'Verbose'
},
debug: {
type: 'boolean',
description: 'Boolean true value',
'x-mcp-header': 'Debug'
},
empty_val: {
type: 'string',
description: 'Empty string value',
'x-mcp-header': 'EmptyVal'
},
method_val: {
type: 'string',
description:
'Value for header named "Method" — tests that x-mcp-header "Method" produces Mcp-Param-Method (not Mcp-Method)',
'x-mcp-header': 'Method'
},
float_val: {
type: 'number',
description: 'Floating point numeric value',
'x-mcp-header': 'FloatVal'
},
non_ascii_val: {
type: 'string',
description:
'Non-ASCII string value — requires Base64 encoding',
'x-mcp-header': 'NonAscii'
},
whitespace_val: {
type: 'string',
description:
'String with leading/trailing whitespace — requires Base64 encoding',
'x-mcp-header': 'Whitespace'
},
leading_space_val: {
type: 'string',
description:
'String with leading space only — requires Base64 encoding',
'x-mcp-header': 'LeadingSpace'
},
trailing_space_val: {
type: 'string',
description:
'String with trailing space only — requires Base64 encoding',
'x-mcp-header': 'TrailingSpace'
},
internal_space_val: {
type: 'string',
description:
'String with internal spaces only — plain ASCII, no Base64',
'x-mcp-header': 'InternalSpace'
},
control_char_val: {
type: 'string',
description:
'String with control characters — requires Base64 encoding',
'x-mcp-header': 'ControlChar'
},
crlf_val: {
type: 'string',
description:
'String with carriage return and line feed — requires Base64 encoding',
'x-mcp-header': 'CrLf'
},
tab_val: {
type: 'string',
description:
'String with leading tab — requires Base64 encoding',
'x-mcp-header': 'Tab'
},
query: {
type: 'string',
description:
'No x-mcp-header annotation - should not be mirrored'
}
},
required: ['region', 'priority', 'query']
}
},
{
name: 'test_custom_headers_null',
description:
'A tool for testing null/omitted x-mcp-header parameter handling',
inputSchema: {
type: 'object',
properties: {
region: {
type: 'string',
description: 'Plain ASCII string value',
'x-mcp-header': 'Region'
},
priority: {
type: 'number',
description: 'Integer numeric value',
'x-mcp-header': 'Priority'
},
verbose: {
type: 'boolean',
description: 'Boolean value — will be null to test omission',
'x-mcp-header': 'Verbose'
},
query: {
type: 'string',
description: 'No x-mcp-header annotation'
}
},
required: ['region', 'priority', 'query']
}
}
]
}
});
}
private handleToolsCall(
req: http.IncomingMessage,
res: http.ServerResponse,
request: any
): void {
const toolName = request.params?.name;
const args = request.params?.arguments || {};
if (toolName === 'test_custom_headers') {
this.toolCallReceived = true;
// Check Mcp-Param-Region header (plain ASCII string)
this.checkParamHeader(req, 'Region', args.region, 'string');
// Check Mcp-Param-Priority header (integer number)
this.checkParamHeader(req, 'Priority', args.priority, 'number');
// Check Mcp-Param-Verbose header (boolean value)
// checkParamHeader already FAILs on missing header, so this also covers
// "optional parameter present → client MUST include header" without a
// separate check id.
if (args.verbose !== undefined && args.verbose !== null) {
this.checkParamHeader(req, 'Verbose', args.verbose, 'boolean');
}
// Check Mcp-Param-Debug header (boolean true value)
if (args.debug !== undefined && args.debug !== null) {
this.checkParamHeader(req, 'Debug', args.debug, 'boolean');
}
// Check Mcp-Param-EmptyVal header (empty string → empty header value)
if (args.empty_val !== undefined && args.empty_val !== null) {
this.checkParamHeader(req, 'EmptyVal', args.empty_val, 'string');
}
// Check Mcp-Param-Method header (x-mcp-header "Method" → Mcp-Param-Method, NOT Mcp-Method)
if (args.method_val !== undefined && args.method_val !== null) {
this.checkParamHeader(req, 'Method', args.method_val, 'string');
}
// Check Mcp-Param-FloatVal header (floating point number)
if (args.float_val !== undefined && args.float_val !== null) {
this.checkParamHeader(req, 'FloatVal', args.float_val, 'number');
}
// Check Mcp-Param-NonAscii header (requires Base64 encoding)
if (args.non_ascii_val !== undefined && args.non_ascii_val !== null) {
this.checkParamHeader(req, 'NonAscii', args.non_ascii_val, 'string');
}
// Check Mcp-Param-Whitespace header (leading/trailing whitespace → Base64)
if (args.whitespace_val !== undefined && args.whitespace_val !== null) {
this.checkParamHeader(req, 'Whitespace', args.whitespace_val, 'string');
}
// Check Mcp-Param-LeadingSpace header (leading space only → Base64)
if (
args.leading_space_val !== undefined &&
args.leading_space_val !== null
) {
this.checkParamHeader(
req,
'LeadingSpace',
args.leading_space_val,
'string'
);
}
// Check Mcp-Param-TrailingSpace header (trailing space only → Base64)
if (
args.trailing_space_val !== undefined &&
args.trailing_space_val !== null
) {
this.checkParamHeader(
req,
'TrailingSpace',
args.trailing_space_val,
'string'
);
}
// Check Mcp-Param-InternalSpace header (internal spaces only → plain ASCII, no Base64)
if (
args.internal_space_val !== undefined &&
args.internal_space_val !== null
) {
this.checkParamHeader(
req,
'InternalSpace',
args.internal_space_val,
'string'
);
}
// Check Mcp-Param-ControlChar header (control characters → Base64)
if (
args.control_char_val !== undefined &&
args.control_char_val !== null
) {
this.checkParamHeader(
req,
'ControlChar',
args.control_char_val,
'string'
);
}
// Check Mcp-Param-CrLf header (carriage return + line feed → Base64)
if (args.crlf_val !== undefined && args.crlf_val !== null) {
this.checkParamHeader(req, 'CrLf', args.crlf_val, 'string');
}
// Check Mcp-Param-Tab header (leading tab → Base64)
if (args.tab_val !== undefined && args.tab_val !== null) {
this.checkParamHeader(req, 'Tab', args.tab_val, 'string');
}
// Check that 'query' (no x-mcp-header) is NOT mirrored
const queryHeader = req.headers['mcp-param-query'] as string | undefined;
this.checks.push({
id: 'sep-2243-no-mirror-unannotated',
name: 'ClientCustomHeaderNoMirrorUnannotated',
description:
'Client MUST NOT add Mcp-Param headers for parameters without x-mcp-header',
status: queryHeader === undefined ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage:
queryHeader !== undefined
? `Found unexpected Mcp-Param-Query header '${queryHeader}' for unannotated parameter`
: undefined,
specReferences: [SPEC_REFERENCE_CUSTOM]
});
} else if (toolName === 'test_custom_headers_null') {
this.nullToolCallReceived = true;
// When value is null or not provided, client MUST omit the header
const verboseHeader = req.headers['mcp-param-verbose'] as
| string
| undefined;
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: verboseHeader === undefined ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage:
verboseHeader !== undefined
? `Mcp-Param-Verbose should be omitted when null/undefined, but got '${verboseHeader}'`
: undefined,
specReferences: [SPEC_REFERENCE_CUSTOM]
});
}
this.sendJson(res, {
jsonrpc: '2.0',
id: request.id,
result: {
content: [{ type: 'text', text: 'Custom headers test completed' }]
}
});
}
private checkParamHeader(
req: http.IncomingMessage,
headerName: string,
bodyValue: any,
valueType: string
): void {
const headerKey = `mcp-param-${headerName.toLowerCase()}`;
const rawHeaderValue = req.headers[headerKey] as string | undefined;
if (bodyValue === undefined || bodyValue === null) return;
const errors: string[] = [];
if (rawHeaderValue === undefined) {
errors.push(
`Missing Mcp-Param-${headerName} header. Client MUST include headers for x-mcp-header parameters.`
);
} else {
// Convert body value to expected string representation
let expectedString: string;
switch (valueType) {
case 'number':
expectedString = String(bodyValue);
break;
case 'boolean':
expectedString = bodyValue ? 'true' : 'false';
break;
default:
expectedString = String(bodyValue);
}
// For numbers, compare numerically to allow for cross-SDK
// floating point representation differences (e.g., "42" vs "42.0").
// See SEP-2243 discussion on number precision.
const validationError = validateEncodedHeader(
rawHeaderValue,
expectedString,
valueType
);
if (validationError) {
errors.push(validationError);
}
}
this.checks.push({
id: `sep-2243-param-header-${headerName.toLowerCase()}`,
name: `ClientCustomHeader_${headerName}`,
description: `Client sends correct Mcp-Param-${headerName} header (${valueType} value)`,
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
specReferences: [SPEC_REFERENCE_ENCODING],
details: {
headerName: `Mcp-Param-${headerName}`,
rawHeaderValue,
bodyValue,
valueType,
needsBase64:
typeof bodyValue === 'string' &&
needsBase64Encoding(String(bodyValue))
}
});
}
}
// ─────────────────────────────────────────────────────────────────────────────
// HttpInvalidToolHeadersScenario - tests that clients reject invalid tools
// ─────────────────────────────────────────────────────────────────────────────
export class HttpInvalidToolHeadersScenario extends BaseHttpScenario {
name = 'http-invalid-tool-headers';
description =
'Tests that client rejects tools with invalid x-mcp-header annotations (SEP-2243)';
allowClientError = true;
private calledTools: Set<string> = new Set();
private toolsListSent = false;
getChecks(): ConformanceCheck[] {
if (!this.toolsListSent) {
this.checks.push({
id: 'sep-2243-invalid-tool-tools-list-gate',
name: 'ClientInvalidToolHeadersToolsList',
description: 'Client requests tools/list',
status: 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: 'Client did not send a tools/list request.',
specReferences: [SPEC_REFERENCE_TOOL_DEF]
});
}
// Check that valid_tool WAS called — proves client kept valid tools
const validToolCalled = this.calledTools.has('valid_tool');
this.checks.push({
id: 'sep-2243-keep-valid-tool',
name: 'ClientKeepsValidTool',
description: 'Client MUST keep valid tools while excluding invalid ones',
status: validToolCalled ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: validToolCalled
? undefined
: "Client did not call 'valid_tool'. A single malformed tool definition must not prevent other valid tools from being used.",
specReferences: [SPEC_REFERENCE_TOOL_DEF]
});
// Check that the client did NOT call any of the invalid tools
const invalidTools = [
'invalid_empty_header',
'invalid_object_header',
'invalid_array_header',
'invalid_null_header',
'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);
this.checks.push({
id: `sep-2243-reject-invalid-tool-${toolName.replace(/_/g, '-')}`,
name: `ClientRejectsInvalidTool_${toolName}`,
description: `Client MUST NOT call tool '${toolName}' with invalid x-mcp-header`,
status: called ? 'FAILURE' : 'SUCCESS',
timestamp: new Date().toISOString(),
errorMessage: called
? `Client called '${toolName}' which has an invalid x-mcp-header. Clients MUST reject (exclude) such tools.`
: undefined,
specReferences: [SPEC_REFERENCE_TOOL_DEF]
});
}
return this.checks;
}
protected handlePost(
_req: http.IncomingMessage,
res: http.ServerResponse,
request: any
): void {
if (request.method === 'initialize') {
this.sendInitialize(res, request);
} else if (request.method === 'tools/list') {
this.handleToolsList(res, request);
} else if (request.method === 'tools/call') {
this.handleToolsCall(res, request);
} else if (request.id === undefined) {
this.sendNotificationAck(res);
} else {
this.sendGenericResult(res, request);
}
}
private handleToolsList(res: http.ServerResponse, request: any): void {
this.toolsListSent = true;
this.sendJson(res, {
jsonrpc: '2.0',
id: request.id,
result: {
tools: [
// ── Valid tool (should be kept by client) ──
{
name: 'valid_tool',
description: 'A valid tool with correct x-mcp-header',
inputSchema: {
type: 'object',
properties: {
region: {
type: 'string',
'x-mcp-header': 'Region'
}
},
required: ['region']
}
},
// ── Invalid: empty x-mcp-header value ──
{
name: 'invalid_empty_header',
description:
'x-mcp-header MUST NOT be empty (MUST be rejected by client)',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string', 'x-mcp-header': '' }
},
required: ['value']
}
},
// ── Invalid: x-mcp-header on object type ──
{
name: 'invalid_object_header',
description:
'x-mcp-header MUST only be on primitive types (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
data: { type: 'object', 'x-mcp-header': 'Data' }
},
required: ['data']
}
},
// ── Invalid: x-mcp-header on array type ──
{
name: 'invalid_array_header',
description:
'x-mcp-header MUST only be on primitive types (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
items: {
type: 'array',
items: { type: 'string' },
'x-mcp-header': 'Items'
}
},
required: ['items']
}
},
// ── Invalid: x-mcp-header on null type ──
{
name: 'invalid_null_header',
description:
'x-mcp-header MUST only be on primitive types (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
nil: { type: 'null', 'x-mcp-header': 'Nil' }
},
required: ['nil']
}
},
// ── Invalid: duplicate same-case x-mcp-header values ──
{
name: 'invalid_duplicate_same_case',
description:
'Duplicate x-mcp-header "Region" on two properties (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
field1: { type: 'string', 'x-mcp-header': 'Region' },
field2: { type: 'string', 'x-mcp-header': 'Region' }
},
required: ['field1', 'field2']
}
},
// ── Invalid: duplicate case-insensitive x-mcp-header values ──
{
name: 'invalid_duplicate_diff_case',
description:
'Duplicate case-insensitive x-mcp-header "MyField"/"myfield" (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
field1: { type: 'string', 'x-mcp-header': 'MyField' },
field2: { type: 'string', 'x-mcp-header': 'myfield' }
},
required: ['field1', 'field2']
}
},
// ── Invalid: space in x-mcp-header name ──
{
name: 'invalid_space_in_name',
description:
'x-mcp-header MUST NOT contain space (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string', 'x-mcp-header': 'My Region' }
},
required: ['value']
}
},
// ── Invalid: colon in x-mcp-header name ──
{
name: 'invalid_colon_in_name',
description:
'x-mcp-header MUST NOT contain colon (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
value: {
type: 'string',
'x-mcp-header': 'Region:Primary'
}
},
required: ['value']
}
},
// ── Invalid: non-ASCII in x-mcp-header name ──
{
name: 'invalid_non_ascii_name',
description:
'x-mcp-header MUST contain only ASCII chars (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string', 'x-mcp-header': 'Région' }
},
required: ['value']
}
},
// ── Invalid: control character in x-mcp-header name ──
{
name: 'invalid_control_char_name',
description:
'x-mcp-header MUST NOT contain control chars (MUST be rejected)',
inputSchema: {
type: 'object',
properties: {
value: { type: 'string', 'x-mcp-header': 'Region\t1' }
},
required: ['value']
}
}
]
}
});
}
private handleToolsCall(res: http.ServerResponse, request: any): void {
const toolName = request.params?.name;
if (toolName) this.calledTools.add(toolName);
this.sendJson(res, {
jsonrpc: '2.0',
id: request.id,
result: {
content: [{ type: 'text', text: 'Tool call received' }]
}
});
}
}