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
4 changed files with 2490 additions and 4 deletions

View file

@ -0,0 +1,156 @@
/**
* Shared HTTP test-server scaffold for client-under-test SEP-2243 scenarios.
*
* A scenario that needs to act as a Streamable-HTTP MCP server, inspect
* incoming client requests, and emit ConformanceChecks should extend this
* class and implement handlePost() + getChecks(). start()/stop() and the
* GET/DELETE/body-parse boilerplate are handled here.
*/
import http from 'http';
import {
Scenario,
ScenarioUrls,
ConformanceCheck,
ScenarioSource,
DRAFT_PROTOCOL_VERSION
} from '../../types.js';
export abstract class BaseHttpScenario implements Scenario {
abstract name: string;
abstract description: string;
readonly source: ScenarioSource = { introducedIn: DRAFT_PROTOCOL_VERSION };
allowClientError?: boolean;
protected server: http.Server | null = null;
protected checks: ConformanceCheck[] = [];
protected port: number = 0;
protected sessionId: string = `session-${Date.now()}`;
async start(): Promise<ScenarioUrls> {
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
this.server.on('error', reject);
this.server.listen(0, () => {
const address = this.server!.address();
if (address && typeof address === 'object') {
this.port = address.port;
resolve({ serverUrl: `http://localhost:${this.port}` });
} else {
reject(new Error('Failed to get server address'));
}
});
});
}
async stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server) {
this.server.close((err) => {
if (err) reject(err);
else {
this.server = null;
resolve();
}
});
} else {
resolve();
}
});
}
abstract getChecks(): ConformanceCheck[];
protected handleRequest(
req: http.IncomingMessage,
res: http.ServerResponse
): void {
if (req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'mcp-session-id': this.sessionId
});
res.write('data: \n\n');
return;
}
if (req.method === 'DELETE') {
res.writeHead(200);
res.end();
return;
}
if (req.method !== 'POST') {
res.writeHead(405);
res.end('Method Not Allowed');
return;
}
// Decode the stream as UTF-8 so multi-byte characters that straddle a
// chunk boundary aren't corrupted by per-chunk Buffer.toString().
req.setEncoding('utf8');
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
const request = JSON.parse(body);
this.handlePost(req, res, request);
} catch (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32700, message: `Parse error: ${error}` }
})
);
}
});
}
protected abstract handlePost(
req: http.IncomingMessage,
res: http.ServerResponse,
request: any
): void;
protected sendJson(res: http.ServerResponse, body: object): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(JSON.stringify(body));
}
protected sendInitialize(
res: http.ServerResponse,
request: any,
capabilities: object = { tools: {} }
): void {
this.sendJson(res, {
jsonrpc: '2.0',
id: request.id,
result: {
protocolVersion: DRAFT_PROTOCOL_VERSION,
serverInfo: { name: this.name + '-server', version: '1.0.0' },
capabilities
}
});
}
protected sendNotificationAck(res: http.ServerResponse): void {
res.writeHead(202);
res.end();
}
protected sendGenericResult(res: http.ServerResponse, request: any): void {
this.sendJson(res, {
jsonrpc: '2.0',
id: request.id,
result: {}
});
}
}

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' }]
}
});
}
}

View file

@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { HttpStandardHeadersScenario } from './http-standard-headers';
/**
* Negative test for SEP-2243 standard-header checks: a client that omits
* Mcp-Method on a POST must produce a FAILURE row, and one that includes it
* must produce SUCCESS. Pins the check id so coverage is tracked.
*/
describe('HttpStandardHeadersScenario (SEP-2243) — negative', () => {
async function postInitialize(
serverUrl: string,
extraHeaders: Record<string, string>
): Promise<void> {
await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...extraHeaders
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: 'DRAFT-2026-v1',
clientInfo: { name: 'neg-test', version: '0' },
capabilities: {}
}
})
});
}
it('FAILs sep-2243-mcp-method-header-initialize when Mcp-Method is missing', async () => {
const scenario = new HttpStandardHeadersScenario();
const { serverUrl } = await scenario.start();
try {
await postInitialize(serverUrl, {}); // no Mcp-Method header
const checks = scenario.getChecks();
const check = checks.find(
(c) => c.id === 'sep-2243-mcp-method-header-initialize'
);
expect(check?.status).toBe('FAILURE');
} finally {
await scenario.stop();
}
});
it('SUCCEEDs sep-2243-mcp-method-header-initialize when Mcp-Method matches', async () => {
const scenario = new HttpStandardHeadersScenario();
const { serverUrl } = await scenario.start();
try {
await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' });
const checks = scenario.getChecks();
const check = checks.find(
(c) => c.id === 'sep-2243-mcp-method-header-initialize'
);
expect(check?.status).toBe('SUCCESS');
} finally {
await scenario.stop();
}
});
it('getChecks() is idempotent', async () => {
const scenario = new HttpStandardHeadersScenario();
const { serverUrl } = await scenario.start();
try {
await postInitialize(serverUrl, { 'Mcp-Method': 'initialize' });
const first = scenario.getChecks();
const second = scenario.getChecks();
expect(second.length).toBe(first.length);
} finally {
await scenario.stop();
}
});
});

View file

@ -0,0 +1,356 @@
/**
* HTTP Standard Headers conformance test scenario for MCP clients (SEP-2243)
*
* Tests that clients include the required standard MCP request headers on
* Streamable HTTP POST requests:
* - `Mcp-Method`: mirrors the `method` field from the JSON-RPC request body
* - `Mcp-Name`: mirrors `params.name` or `params.uri` for tools/call,
* resources/read, and prompts/get requests
*
* This is a Scenario (acts as a test server that inspects incoming requests
* from the client under test).
*/
import http from 'http';
import { ConformanceCheck } from '../../types.js';
import { BaseHttpScenario } from './http-base.js';
const SPEC_REFERENCE = {
id: 'SEP-2243-Standard-Headers',
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers'
};
export class HttpStandardHeadersScenario extends BaseHttpScenario {
name = 'http-standard-headers';
description =
'Tests that client includes Mcp-Method and Mcp-Name headers on HTTP POST requests (SEP-2243)';
// Track which header checks have been recorded
private methodHeaderChecks = new Map<string, boolean>();
// Track which Mcp-Name checks have been recorded
private nameHeaderChecks = new Map<string, boolean>();
getChecks(): ConformanceCheck[] {
// Build a fresh array each call so getChecks() is idempotent — the runner
// may call it more than once and we must not accumulate duplicates.
const result = [...this.checks];
// SEP-2243 requires Mcp-Method on "all requests and notifications". A
// client that never sent prompts/list isn't violating SEP-2243 — it just
// didn't exercise that path. Emit SKIPPED (not FAILURE) so a prompts-less
// client doesn't show red, but the gap is still visible in the report.
const expectedMethods = [
'initialize',
'notifications/initialized',
'tools/list',
'tools/call',
'resources/list',
'resources/read',
'prompts/list',
'prompts/get'
];
for (const method of expectedMethods) {
if (!this.methodHeaderChecks.has(method)) {
result.push({
id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`,
name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Method header on ${method} request`,
status: 'SKIPPED',
timestamp: new Date().toISOString(),
errorMessage: `Client did not send a ${method} request; Mcp-Method header was not exercised for this method.`,
specReferences: [SPEC_REFERENCE]
});
}
}
const expectedNameMethods = ['tools/call', 'resources/read', 'prompts/get'];
for (const method of expectedNameMethods) {
if (!this.nameHeaderChecks.has(method)) {
result.push({
id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`,
name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Name header on ${method} request`,
status: 'SKIPPED',
timestamp: new Date().toISOString(),
errorMessage: `Client did not send a ${method} request; Mcp-Name header was not exercised for this method.`,
specReferences: [SPEC_REFERENCE]
});
}
}
return result;
}
protected handlePost(
req: http.IncomingMessage,
res: http.ServerResponse,
request: any
): void {
// Check Mcp-Method header for every request
this.checkMcpMethodHeader(req, request);
// Route to handlers
if (request.method === 'initialize') {
this.handleInitialize(res, request);
} else if (request.method === 'tools/list') {
this.handleToolsList(res, request);
} else if (request.method === 'tools/call') {
this.checkMcpNameHeader(req, request, 'params.name');
this.handleToolsCall(res, request);
} else if (request.method === 'resources/list') {
this.handleResourcesList(res, request);
} else if (request.method === 'resources/read') {
this.checkMcpNameHeader(req, request, 'params.uri');
this.handleResourcesRead(res, request);
} else if (request.method === 'prompts/list') {
this.handlePromptsList(res, request);
} else if (request.method === 'prompts/get') {
this.checkMcpNameHeader(req, request, 'params.name');
this.handlePromptsGet(res, request);
} else if (request.id === undefined) {
// Notifications - return 202 (Mcp-Method already checked above)
this.sendNotificationAck(res);
} else {
this.sendGenericResult(res, request);
}
}
private checkMcpMethodHeader(req: http.IncomingMessage, request: any): void {
const method = request.method;
if (!method) return;
// Already recorded a check for this method
if (this.methodHeaderChecks.has(method)) return;
// Header names are lowercased by Node.js http parser
const mcpMethodHeader = req.headers['mcp-method'] as string | undefined;
const errors: string[] = [];
if (!mcpMethodHeader) {
errors.push(
`Missing Mcp-Method header on ${method} request. Clients MUST include the Mcp-Method header on all POST requests.`
);
} else if (mcpMethodHeader !== method) {
// Header values are case-sensitive
errors.push(
`Mcp-Method header value '${mcpMethodHeader}' does not match body method '${method}'. Header values are case-sensitive.`
);
}
this.methodHeaderChecks.set(method, errors.length === 0);
this.checks.push({
id: `sep-2243-mcp-method-header-${method.replace(/\//g, '-')}`,
name: `ClientMcpMethodHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Method header on ${method} request`,
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
specReferences: [SPEC_REFERENCE],
details: {
expectedMethod: method,
actualHeader: mcpMethodHeader
}
});
}
private checkMcpNameHeader(
req: http.IncomingMessage,
request: any,
sourceField: string
): void {
const method = request.method;
// Same de-dup guard as checkMcpMethodHeader: the harness advertises two
// tools and two resources, so a client that calls both would otherwise
// produce duplicate check rows for the same id.
if (this.nameHeaderChecks.has(method)) return;
const expectedValue =
sourceField === 'params.uri' ? request.params?.uri : request.params?.name;
const mcpNameHeader = req.headers['mcp-name'] as string | undefined;
const errors: string[] = [];
if (!mcpNameHeader) {
errors.push(
`Missing Mcp-Name header on ${method} request. Clients MUST include the Mcp-Name header for ${method} requests.`
);
} else if (mcpNameHeader !== expectedValue) {
errors.push(
`Mcp-Name header value '${mcpNameHeader}' does not match body ${sourceField} '${expectedValue}'.`
);
}
this.nameHeaderChecks.set(method, errors.length === 0);
this.checks.push({
id: `sep-2243-mcp-name-header-${method.replace(/\//g, '-')}`,
name: `ClientMcpNameHeader_${method.replace(/\//g, '_')}`,
description: `Client sends correct Mcp-Name header on ${method} request`,
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
timestamp: new Date().toISOString(),
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
specReferences: [SPEC_REFERENCE],
details: {
method,
sourceField,
expectedValue,
actualHeader: mcpNameHeader
}
});
}
private handleInitialize(res: http.ServerResponse, request: any): void {
this.sendInitialize(res, request, {
tools: {},
resources: {},
prompts: {}
});
}
private handleToolsList(res: http.ServerResponse, request: any): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: request.id,
result: {
tools: [
{
name: 'test_headers',
description:
'A simple tool used to test that HTTP headers are sent correctly',
inputSchema: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'my-hyphenated-tool',
description:
'Tool with hyphen in name to test special chars in Mcp-Name header',
inputSchema: {
type: 'object',
properties: {},
required: []
}
}
]
}
})
);
}
private handleToolsCall(res: http.ServerResponse, request: any): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: request.id,
result: {
content: [
{
type: 'text',
text: 'Headers test completed'
}
]
}
})
);
}
private handleResourcesList(res: http.ServerResponse, request: any): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: request.id,
result: {
resources: [
{
uri: 'file:///path/to/file%20name.txt',
name: 'File with spaces',
description: 'Resource URI with percent-encoded spaces'
},
{
uri: 'https://example.com/resource?id=123',
name: 'Resource with query string',
description: 'Resource URI with query string'
}
]
}
})
);
}
private handleResourcesRead(res: http.ServerResponse, request: any): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: request.id,
result: {
contents: []
}
})
);
}
private handlePromptsList(res: http.ServerResponse, request: any): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: request.id,
result: {
prompts: [
{
name: 'test_prompt',
description: 'A simple prompt for header testing'
}
]
}
})
);
}
private handlePromptsGet(res: http.ServerResponse, request: any): void {
res.writeHead(200, {
'Content-Type': 'application/json',
'mcp-session-id': this.sessionId
});
res.end(
JSON.stringify({
jsonrpc: '2.0',
id: request.id,
result: {
messages: []
}
})
);
}
}