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:
parent
acc70de0f6
commit
39f34b2c58
8 changed files with 2490 additions and 4 deletions
|
|
@ -21,7 +21,8 @@
|
|||
"tier-check": "node dist/index.js tier-check",
|
||||
"check": "npm run typecheck && npm run lint",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"prepack": "npm run build"
|
||||
"prepack": "npm run build",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
|
|
|||
156
src/scenarios/client/http-base.ts
Normal file
156
src/scenarios/client/http-base.ts
Normal 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: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
879
src/scenarios/client/http-custom-headers.ts
Normal file
879
src/scenarios/client/http-custom-headers.ts
Normal 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' }]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
76
src/scenarios/client/http-standard-headers.test.ts
Normal file
76
src/scenarios/client/http-standard-headers.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
356
src/scenarios/client/http-standard-headers.ts
Normal file
356
src/scenarios/client/http-standard-headers.ts
Normal 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: []
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,11 @@ import {
|
|||
|
||||
import { DNSRebindingProtectionScenario } from './server/dns-rebinding';
|
||||
|
||||
import {
|
||||
HttpHeaderValidationScenario,
|
||||
HttpCustomHeaderServerValidationScenario
|
||||
} from './server/http-standard-headers';
|
||||
|
||||
import {
|
||||
authScenariosList,
|
||||
backcompatScenariosList,
|
||||
|
|
@ -73,6 +78,12 @@ import {
|
|||
import { listMetadataScenarios } from './client/auth/discovery-metadata';
|
||||
import { AuthorizationServerMetadataEndpointScenario } from './authorization-server/authorization-server-metadata';
|
||||
|
||||
import { HttpStandardHeadersScenario } from './client/http-standard-headers';
|
||||
import {
|
||||
HttpCustomHeadersScenario,
|
||||
HttpInvalidToolHeadersScenario
|
||||
} from './client/http-custom-headers';
|
||||
|
||||
// Pending client scenarios (not yet fully tested/implemented)
|
||||
const pendingClientScenariosList: ClientScenario[] = [
|
||||
// JSON Schema 2020-12 (SEP-1613)
|
||||
|
|
@ -82,7 +93,13 @@ const pendingClientScenariosList: ClientScenario[] = [
|
|||
|
||||
// On hold until server-side SSE improvements are made
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1129
|
||||
new ServerSSEPollingScenario()
|
||||
new ServerSSEPollingScenario(),
|
||||
|
||||
// HTTP Standardization (SEP-2243)
|
||||
// Pending until the everything-server fully implements SEP-2243
|
||||
// header validation (case-insensitive names, whitespace trimming, -32001 error code)
|
||||
new HttpHeaderValidationScenario(),
|
||||
new HttpCustomHeaderServerValidationScenario()
|
||||
];
|
||||
|
||||
// All client scenarios
|
||||
|
|
@ -140,7 +157,11 @@ const allClientScenariosList: ClientScenario[] = [
|
|||
new PromptsGetWithImageScenario(),
|
||||
|
||||
// Security scenarios
|
||||
new DNSRebindingProtectionScenario()
|
||||
new DNSRebindingProtectionScenario(),
|
||||
|
||||
// HTTP Standardization scenarios (SEP-2243)
|
||||
new HttpHeaderValidationScenario(),
|
||||
new HttpCustomHeaderServerValidationScenario()
|
||||
];
|
||||
|
||||
// Active client scenarios (excludes pending)
|
||||
|
|
@ -183,7 +204,12 @@ const scenariosList: Scenario[] = [
|
|||
...authScenariosList,
|
||||
...backcompatScenariosList,
|
||||
...draftScenariosList,
|
||||
...extensionScenariosList
|
||||
...extensionScenariosList,
|
||||
|
||||
// HTTP Standardization scenarios (SEP-2243)
|
||||
new HttpStandardHeadersScenario(),
|
||||
new HttpCustomHeadersScenario(),
|
||||
new HttpInvalidToolHeadersScenario()
|
||||
];
|
||||
|
||||
// Core scenarios (tier 1 requirements)
|
||||
|
|
|
|||
933
src/scenarios/server/http-standard-headers.ts
Normal file
933
src/scenarios/server/http-standard-headers.ts
Normal file
|
|
@ -0,0 +1,933 @@
|
|||
/**
|
||||
* HTTP Standard Headers server validation test scenarios (SEP-2243)
|
||||
*
|
||||
* Tests that servers properly validate the standard MCP request headers:
|
||||
* - Reject requests where Mcp-Method header doesn't match the body
|
||||
* - Reject requests where Mcp-Name header doesn't match the body
|
||||
* - Accept case variations of header names (case-insensitive)
|
||||
* - Reject case variations of header values (case-sensitive)
|
||||
* - Handle whitespace trimming per HTTP spec
|
||||
* - Validate Base64-encoded custom header values
|
||||
* - Return 400 Bad Request with error code -32001 (HeaderMismatch)
|
||||
*
|
||||
* This is a ClientScenario (connects to a server under test and validates
|
||||
* its behavior).
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import {
|
||||
ClientScenario,
|
||||
ConformanceCheck,
|
||||
DRAFT_PROTOCOL_VERSION
|
||||
} from '../../types';
|
||||
import { connectToServer } from './client-helper';
|
||||
|
||||
const SPEC_REFERENCE = {
|
||||
id: 'SEP-2243-Server-Validation',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-validation'
|
||||
};
|
||||
|
||||
const SPEC_REFERENCE_CASE = {
|
||||
id: 'SEP-2243-Case-Sensitivity',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#case-sensitivity'
|
||||
};
|
||||
|
||||
// OWS handling is an RFC 9110 §5.5 MUST ("a field parsing implementation MUST
|
||||
// exclude such whitespace prior to evaluating the field value"), not a
|
||||
// SEP-2243 requirement. Kept as a check because a server stack that fails it
|
||||
// has a real HTTP-layer bug that will manifest as header-mismatch rejections.
|
||||
const SPEC_REFERENCE_RFC9110_OWS = {
|
||||
id: 'RFC-9110-5.5-Field-Values',
|
||||
url: 'https://www.rfc-editor.org/rfc/rfc9110#section-5.5'
|
||||
};
|
||||
|
||||
const SPEC_REFERENCE_BASE64 = {
|
||||
id: 'SEP-2243-Value-Encoding',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#value-encoding'
|
||||
};
|
||||
|
||||
const SPEC_REFERENCE_CUSTOM = {
|
||||
id: 'SEP-2243-Custom-Headers',
|
||||
url: 'https://modelcontextprotocol.io/specification/draft/basic/transports#server-behavior-for-custom-headers'
|
||||
};
|
||||
|
||||
const HEADER_MISMATCH_ERROR_CODE = -32001;
|
||||
|
||||
/**
|
||||
* Helper to send a raw HTTP POST request with custom headers.
|
||||
* Uses Node.js http.request to preserve exact header casing and values,
|
||||
* avoiding normalization that fetch()/Headers may apply.
|
||||
*/
|
||||
async function sendRawRequest(
|
||||
serverUrl: string,
|
||||
body: object,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<{ status: number; body: any; headers: http.IncomingHttpHeaders }> {
|
||||
const url = new URL(serverUrl);
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Length': Buffer.byteLength(bodyStr),
|
||||
...headers
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
res.setEncoding('utf8');
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
let responseBody: any;
|
||||
const contentType = res.headers['content-type'];
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
responseBody = JSON.parse(data);
|
||||
} catch {
|
||||
responseBody = data;
|
||||
}
|
||||
} else {
|
||||
responseBody = data;
|
||||
}
|
||||
resolve({
|
||||
status: res.statusCode || 0,
|
||||
body: responseBody,
|
||||
headers: res.headers
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds two checks for a rejection case: one for the HTTP 400 status, one for
|
||||
* the -32001 JSON-RPC error code. Per SEP-2243 §Server Validation, 400 is MUST
|
||||
* but -32001 is SHOULD for *standard* headers (and MUST for *custom* headers,
|
||||
* §Server Behavior for Custom Headers) — so a server returning 400 with a
|
||||
* different error code is compliant for standard headers and must not FAIL.
|
||||
*/
|
||||
function createRejectionChecks(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
response: { status: number; body: any },
|
||||
specRef: { id: string; url: string },
|
||||
details: Record<string, unknown>,
|
||||
opts: { errorCodeSeverity: 'FAILURE' | 'WARNING' }
|
||||
): ConformanceCheck[] {
|
||||
const fullDetails = {
|
||||
...details,
|
||||
responseStatus: response.status,
|
||||
responseBody: response.body
|
||||
};
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
const statusOk = response.status === 400;
|
||||
const codeOk = response.body?.error?.code === HEADER_MISMATCH_ERROR_CODE;
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status: statusOk ? 'SUCCESS' : 'FAILURE',
|
||||
timestamp: ts,
|
||||
errorMessage: statusOk
|
||||
? undefined
|
||||
: `Expected HTTP 400, got ${response.status}. Server MUST reject with 400 Bad Request.`,
|
||||
specReferences: [specRef],
|
||||
details: fullDetails
|
||||
},
|
||||
{
|
||||
id: `${id}-error-code`,
|
||||
name: `${name}ErrorCode`,
|
||||
description: `${description} — uses JSON-RPC error code -32001 (HeaderMismatch)`,
|
||||
status: codeOk ? 'SUCCESS' : opts.errorCodeSeverity,
|
||||
timestamp: ts,
|
||||
errorMessage: codeOk
|
||||
? undefined
|
||||
: `Expected JSON-RPC error code ${HEADER_MISMATCH_ERROR_CODE} (HeaderMismatch), got ${response.body?.error?.code ?? '(missing)'}.`,
|
||||
specReferences: [specRef],
|
||||
details: fullDetails
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createAcceptanceCheck(
|
||||
id: string,
|
||||
name: string,
|
||||
description: string,
|
||||
response: { status: number; body: any },
|
||||
specRef: { id: string; url: string },
|
||||
details: Record<string, unknown>
|
||||
): ConformanceCheck {
|
||||
const errors: string[] = [];
|
||||
if (response.status >= 400) {
|
||||
errors.push(
|
||||
`Expected successful response, got HTTP ${response.status}. Server MUST accept this request.`
|
||||
);
|
||||
}
|
||||
// A server can return HTTP 200 with a JSON-RPC error in the body. Without
|
||||
// this assertion that case would pass as "accepted".
|
||||
if (
|
||||
response.body &&
|
||||
typeof response.body === 'object' &&
|
||||
'error' in response.body
|
||||
) {
|
||||
errors.push(
|
||||
`Expected successful response, but body contains JSON-RPC error ${JSON.stringify(response.body.error)}.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status: errors.length === 0 ? 'SUCCESS' : 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: errors.length > 0 ? errors.join('; ') : undefined,
|
||||
specReferences: [specRef],
|
||||
details: {
|
||||
...details,
|
||||
responseStatus: response.status,
|
||||
responseBody: response.body
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class HttpHeaderValidationScenario implements ClientScenario {
|
||||
name = 'http-header-validation';
|
||||
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||
description = `Test server validation of standard MCP request headers (SEP-2243).
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
||||
**Endpoint**: Streamable HTTP
|
||||
|
||||
**Requirements**:
|
||||
- Server MUST reject requests where Mcp-Method header doesn't match the body method
|
||||
- Server MUST reject requests where Mcp-Name header doesn't match the body params.name/uri
|
||||
- Server MUST accept header names case-insensitively
|
||||
- Server MUST reject case-mismatched header values (method values are case-sensitive)
|
||||
- Server MUST accept extra whitespace around header values (per HTTP spec)
|
||||
- Server MUST return HTTP 400 Bad Request for validation failures
|
||||
- Server MUST return JSON-RPC error with code -32001 (HeaderMismatch)`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
try {
|
||||
// Establish a session via normal SDK initialization
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const toolsResult = await connection.client.listTools();
|
||||
await connection.close();
|
||||
|
||||
// Get a fresh session for raw requests
|
||||
const initResponse = await sendRawRequest(
|
||||
serverUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: DRAFT_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'conformance-test-raw-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 'Mcp-Method': 'initialize' }
|
||||
);
|
||||
|
||||
if (initResponse.status === 200) {
|
||||
const rawSid = initResponse.headers['mcp-session-id'];
|
||||
sessionId = (Array.isArray(rawSid) ? rawSid[0] : rawSid) || null;
|
||||
const notifHeaders: Record<string, string> = {
|
||||
'Mcp-Method': 'notifications/initialized'
|
||||
};
|
||||
if (sessionId) notifHeaders['mcp-session-id'] = sessionId;
|
||||
await sendRawRequest(
|
||||
serverUrl,
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||
notifHeaders
|
||||
);
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION
|
||||
};
|
||||
if (sessionId) baseHeaders['mcp-session-id'] = sessionId;
|
||||
|
||||
let idCounter = 100;
|
||||
const nextId = () => idCounter++;
|
||||
|
||||
// --- Header/Body Mismatch Tests ---
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-mismatched-method-header',
|
||||
'ServerRejectsMismatchedMethodHeader',
|
||||
'Server rejects requests where Mcp-Method header does not match body method',
|
||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||
{ 'Mcp-Method': 'prompts/list' },
|
||||
SPEC_REFERENCE,
|
||||
{ requestBodyMethod: 'tools/list', mcpMethodHeader: 'prompts/list' }
|
||||
);
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-missing-method-header',
|
||||
'ServerRejectsMissingMethodHeader',
|
||||
'Server rejects requests with missing Mcp-Method header',
|
||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||
{},
|
||||
SPEC_REFERENCE,
|
||||
{ requestBodyMethod: 'tools/list', mcpMethodHeader: '(missing)' }
|
||||
);
|
||||
|
||||
if (toolsResult.tools && toolsResult.tools.length > 0) {
|
||||
const toolName = toolsResult.tools[0].name;
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-mismatched-name-header',
|
||||
'ServerRejectsMismatchedNameHeader',
|
||||
'Server rejects tools/call where Mcp-Name does not match body params.name',
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: {} }
|
||||
},
|
||||
{ 'Mcp-Method': 'tools/call', 'Mcp-Name': 'wrong_tool_name' },
|
||||
SPEC_REFERENCE,
|
||||
{ requestBodyName: toolName, mcpNameHeader: 'wrong_tool_name' }
|
||||
);
|
||||
|
||||
// --- Whitespace Test ---
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'accept',
|
||||
'sep-2243-server-accepts-whitespace-header-value',
|
||||
'ServerAcceptsWhitespaceHeaderValue',
|
||||
'Server MUST accept leading/trailing whitespace in Mcp-Name value (RFC 9110 §5.5: field parsing MUST exclude OWS before evaluating)',
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: {} }
|
||||
},
|
||||
{
|
||||
'Mcp-Method': 'tools/call',
|
||||
'Mcp-Name': ` ${toolName} `
|
||||
},
|
||||
SPEC_REFERENCE_RFC9110_OWS,
|
||||
{
|
||||
headerValue: ` ${toolName} `,
|
||||
bodyValue: toolName,
|
||||
reason: 'HTTP spec requires trimming OWS around field values'
|
||||
}
|
||||
);
|
||||
|
||||
// --- Missing Standard Header with Value in Body (Case 47) ---
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-missing-name-header',
|
||||
'ServerRejectsMissingNameHeader',
|
||||
'Server MUST reject tools/call with missing Mcp-Name header when body has params.name',
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'tools/call',
|
||||
params: { name: toolName, arguments: {} }
|
||||
},
|
||||
{ 'Mcp-Method': 'tools/call' },
|
||||
SPEC_REFERENCE,
|
||||
{
|
||||
requestBodyName: toolName,
|
||||
mcpNameHeader: '(missing)',
|
||||
reason:
|
||||
'Standard header omitted but value present in body → MUST reject'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// --- Case Sensitivity Tests ---
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'accept',
|
||||
'sep-2243-server-accepts-lowercase-header-name',
|
||||
'ServerAcceptsLowercaseHeaderName',
|
||||
'Server MUST accept lowercase header name (mcp-method)',
|
||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||
{ 'mcp-method': 'tools/list' },
|
||||
SPEC_REFERENCE_CASE,
|
||||
{ headerNameUsed: 'mcp-method' }
|
||||
);
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'accept',
|
||||
'sep-2243-server-accepts-uppercase-header-name',
|
||||
'ServerAcceptsUppercaseHeaderName',
|
||||
'Server MUST accept uppercase header name (MCP-METHOD)',
|
||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||
{ 'MCP-METHOD': 'tools/list' },
|
||||
SPEC_REFERENCE_CASE,
|
||||
{ headerNameUsed: 'MCP-METHOD' }
|
||||
);
|
||||
|
||||
await this.testCase(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-case-mismatch-value',
|
||||
'ServerRejectsCaseMismatchValue',
|
||||
'Server MUST reject uppercase method value (TOOLS/LIST) since values are case-sensitive',
|
||||
{ jsonrpc: '2.0', id: 0, method: 'tools/list' },
|
||||
{ 'Mcp-Method': 'TOOLS/LIST' },
|
||||
SPEC_REFERENCE_CASE,
|
||||
{ headerValue: 'TOOLS/LIST', bodyValue: 'tools/list' }
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2243-server-standard-setup',
|
||||
name: 'HttpHeaderValidationSetup',
|
||||
description: 'Setup for header validation tests',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Failed to set up tests: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: [SPEC_REFERENCE]
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
private async testCase(
|
||||
checks: ConformanceCheck[],
|
||||
serverUrl: string,
|
||||
baseHeaders: Record<string, string>,
|
||||
nextId: () => number,
|
||||
expectation: 'accept' | 'reject',
|
||||
checkId: string,
|
||||
checkName: string,
|
||||
description: string,
|
||||
body: any,
|
||||
extraHeaders: Record<string, string>,
|
||||
specRef: { id: string; url: string },
|
||||
details: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const requestBody = { ...body, id: body.id === 0 ? nextId() : body.id };
|
||||
const response = await sendRawRequest(serverUrl, requestBody, {
|
||||
...baseHeaders,
|
||||
...extraHeaders
|
||||
});
|
||||
if (expectation === 'reject') {
|
||||
// Standard-header rejection: 400 is MUST, -32001 is SHOULD.
|
||||
checks.push(
|
||||
...createRejectionChecks(
|
||||
checkId,
|
||||
checkName,
|
||||
description,
|
||||
response,
|
||||
specRef,
|
||||
details,
|
||||
{ errorCodeSeverity: 'WARNING' }
|
||||
)
|
||||
);
|
||||
} else {
|
||||
checks.push(
|
||||
createAcceptanceCheck(
|
||||
checkId,
|
||||
checkName,
|
||||
description,
|
||||
response,
|
||||
specRef,
|
||||
details
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: checkId,
|
||||
name: checkName,
|
||||
description,
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: [specRef]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpCustomHeaderServerValidationScenario implements ClientScenario {
|
||||
name = 'http-custom-header-server-validation';
|
||||
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||
description = `Test server validation of custom Mcp-Param headers and Base64 encoding (SEP-2243).
|
||||
|
||||
**Server Implementation Requirements:**
|
||||
|
||||
**Endpoint**: Streamable HTTP with at least one tool using \`x-mcp-header\`
|
||||
|
||||
**Requirements**:
|
||||
- Server MUST validate Base64-encoded header values
|
||||
- Server MUST reject requests with invalid Base64 padding or characters
|
||||
- Server MUST treat values without =?base64?...?= wrapper as literal
|
||||
- Server MUST reject requests where custom header is omitted but value is in body`;
|
||||
|
||||
async run(serverUrl: string): Promise<ConformanceCheck[]> {
|
||||
const checks: ConformanceCheck[] = [];
|
||||
let sessionId: string | null = null;
|
||||
|
||||
try {
|
||||
const connection = await connectToServer(serverUrl);
|
||||
const toolsResult = await connection.client.listTools();
|
||||
await connection.close();
|
||||
|
||||
// Find a tool with x-mcp-header annotations
|
||||
const xMcpTool = toolsResult.tools?.find((tool) => {
|
||||
const schema = tool.inputSchema as any;
|
||||
if (!schema?.properties) return false;
|
||||
return Object.values(schema.properties).some(
|
||||
(prop: any) => prop['x-mcp-header'] !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
if (!xMcpTool) {
|
||||
checks.push({
|
||||
id: 'sep-2243-server-no-xmcp-tool',
|
||||
name: 'HttpCustomHeaderServerNoTool',
|
||||
description:
|
||||
'Server has no tools with x-mcp-header annotations to test',
|
||||
status: 'SKIPPED',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SPEC_REFERENCE_CUSTOM],
|
||||
details: {
|
||||
reason:
|
||||
'No tools with x-mcp-header found. These tests require at least one tool with x-mcp-header annotations.'
|
||||
}
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
|
||||
// Get a fresh session for raw requests
|
||||
const initResponse = await sendRawRequest(
|
||||
serverUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: DRAFT_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'conformance-test-base64-client',
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 'Mcp-Method': 'initialize' }
|
||||
);
|
||||
|
||||
if (initResponse.status === 200) {
|
||||
const rawSid2 = initResponse.headers['mcp-session-id'];
|
||||
sessionId = (Array.isArray(rawSid2) ? rawSid2[0] : rawSid2) || null;
|
||||
const notifHeaders: Record<string, string> = {
|
||||
'Mcp-Method': 'notifications/initialized'
|
||||
};
|
||||
if (sessionId) notifHeaders['mcp-session-id'] = sessionId;
|
||||
await sendRawRequest(
|
||||
serverUrl,
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||
notifHeaders
|
||||
);
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'MCP-Protocol-Version': DRAFT_PROTOCOL_VERSION
|
||||
};
|
||||
if (sessionId) baseHeaders['mcp-session-id'] = sessionId;
|
||||
|
||||
// Find the first x-mcp-header annotated STRING property
|
||||
// that is callable with minimal arguments to avoid schema validation failures
|
||||
const schema = xMcpTool.inputSchema as any;
|
||||
const annotatedEntry = Object.entries(schema.properties).find(
|
||||
([, def]: [string, any]) =>
|
||||
def['x-mcp-header'] !== undefined && (def as any).type === 'string'
|
||||
);
|
||||
if (!annotatedEntry) {
|
||||
checks.push({
|
||||
id: 'sep-2243-server-no-string-param',
|
||||
name: 'HttpCustomHeaderServerNoStringParam',
|
||||
description:
|
||||
'Server has no string-typed x-mcp-header parameter to test',
|
||||
status: 'SKIPPED',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||
});
|
||||
return checks;
|
||||
}
|
||||
const [paramName, paramDef] = annotatedEntry as [string, any];
|
||||
const headerSuffix = paramDef['x-mcp-header'];
|
||||
|
||||
// Build default arguments for all required params to avoid schema validation errors.
|
||||
// These go in the JSON body, so number/boolean must be the real types —
|
||||
// sending '0' or 'false' as strings makes the server reject on JSON-schema
|
||||
// grounds and the header-validation checks below would false-pass on that 400.
|
||||
const requiredParams: string[] = schema.required || [];
|
||||
const defaultArgs: Record<string, string | number | boolean> = {};
|
||||
const defaultHeaders: Record<string, string> = {};
|
||||
for (const rp of requiredParams) {
|
||||
if (rp !== paramName) {
|
||||
const rpDef = schema.properties[rp];
|
||||
const rpType = rpDef?.type || 'string';
|
||||
if (rpType === 'number' || rpType === 'integer') {
|
||||
defaultArgs[rp] = 0;
|
||||
} else if (rpType === 'boolean') {
|
||||
defaultArgs[rp] = false;
|
||||
} else {
|
||||
defaultArgs[rp] = 'test-default';
|
||||
}
|
||||
// If this required param also has x-mcp-header, include its header too
|
||||
if (rpDef?.['x-mcp-header']) {
|
||||
defaultHeaders[`Mcp-Param-${rpDef['x-mcp-header']}`] = String(
|
||||
defaultArgs[rp]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let idCounter = 200;
|
||||
const nextId = () => idCounter++;
|
||||
|
||||
// --- Base64 Decoding Tests ---
|
||||
|
||||
const validBase64Value = Buffer.from('Hello').toString('base64');
|
||||
|
||||
// Valid Base64 - server decodes and validates
|
||||
await this.testBase64Case(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'accept',
|
||||
'sep-2243-server-accepts-valid-base64',
|
||||
'ServerAcceptsValidBase64',
|
||||
'Server decodes valid Base64 header value and validates against body',
|
||||
xMcpTool.name,
|
||||
paramName,
|
||||
'Hello',
|
||||
headerSuffix,
|
||||
`=?base64?${validBase64Value}?=`,
|
||||
defaultArgs,
|
||||
defaultHeaders
|
||||
);
|
||||
|
||||
// Invalid Base64 padding — FAILURE per the SEP-2243 conformance-test-case
|
||||
// table, which is the approved source of truth for these cases. The spec
|
||||
// body says only "MUST decode them accordingly", but the table specifies
|
||||
// strict rejection. SDKs whose stdlib decoders are lenient (Node
|
||||
// Buffer.from, browser atob) will need to validate before decoding; if
|
||||
// that proves burdensome we'll revisit.
|
||||
await this.testBase64Case(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-invalid-base64-padding',
|
||||
'ServerRejectsInvalidBase64Padding',
|
||||
'Server MUST reject Mcp-Param header with invalid Base64 padding (per SEP-2243 test-case table)',
|
||||
xMcpTool.name,
|
||||
paramName,
|
||||
'Hello',
|
||||
headerSuffix,
|
||||
'=?base64?SGVsbG8?=',
|
||||
defaultArgs,
|
||||
defaultHeaders
|
||||
);
|
||||
|
||||
// Invalid Base64 characters — FAILURE for the same reason as padding.
|
||||
await this.testBase64Case(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'reject',
|
||||
'sep-2243-server-rejects-invalid-base64-chars',
|
||||
'ServerRejectsInvalidBase64Chars',
|
||||
'Server MUST reject Mcp-Param header with non-alphabet Base64 characters (per SEP-2243 test-case table)',
|
||||
xMcpTool.name,
|
||||
paramName,
|
||||
'Hello',
|
||||
headerSuffix,
|
||||
'=?base64?SGVs!!!bG8=?=',
|
||||
defaultArgs,
|
||||
defaultHeaders
|
||||
);
|
||||
|
||||
// Missing prefix - server treats as literal value
|
||||
await this.testBase64Case(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'accept',
|
||||
'sep-2243-server-literal-missing-base64-prefix',
|
||||
'ServerLiteralMissingBase64Prefix',
|
||||
'Server treats value without =?base64? prefix as literal (not Base64)',
|
||||
xMcpTool.name,
|
||||
paramName,
|
||||
validBase64Value,
|
||||
headerSuffix,
|
||||
validBase64Value,
|
||||
defaultArgs,
|
||||
defaultHeaders
|
||||
);
|
||||
|
||||
// Missing suffix - server treats as literal value
|
||||
await this.testBase64Case(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
'accept',
|
||||
'sep-2243-server-literal-missing-base64-suffix',
|
||||
'ServerLiteralMissingBase64Suffix',
|
||||
'Server treats value without ?= suffix as literal (not Base64)',
|
||||
xMcpTool.name,
|
||||
paramName,
|
||||
`=?base64?${validBase64Value}`,
|
||||
headerSuffix,
|
||||
`=?base64?${validBase64Value}`,
|
||||
defaultArgs,
|
||||
defaultHeaders
|
||||
);
|
||||
|
||||
// --- Missing Custom Header with Value in Body ---
|
||||
|
||||
await this.testMissingCustomHeader(
|
||||
checks,
|
||||
serverUrl,
|
||||
baseHeaders,
|
||||
nextId,
|
||||
xMcpTool.name,
|
||||
paramName,
|
||||
headerSuffix,
|
||||
defaultArgs,
|
||||
defaultHeaders
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2243-server-custom-setup',
|
||||
name: 'HttpCustomHeaderServerValidationSetup',
|
||||
description: 'Setup for custom header server validation tests',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
private async testBase64Case(
|
||||
checks: ConformanceCheck[],
|
||||
serverUrl: string,
|
||||
baseHeaders: Record<string, string>,
|
||||
nextId: () => number,
|
||||
expectation: 'accept' | 'reject',
|
||||
checkId: string,
|
||||
checkName: string,
|
||||
description: string,
|
||||
toolName: string,
|
||||
paramName: string,
|
||||
bodyValue: string,
|
||||
headerSuffix: string,
|
||||
headerValue: string,
|
||||
defaultArgs: Record<string, any>,
|
||||
defaultHeaders: Record<string, string>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await sendRawRequest(
|
||||
serverUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: { ...defaultArgs, [paramName]: bodyValue }
|
||||
}
|
||||
},
|
||||
{
|
||||
...baseHeaders,
|
||||
...defaultHeaders,
|
||||
'Mcp-Method': 'tools/call',
|
||||
'Mcp-Name': toolName,
|
||||
[`Mcp-Param-${headerSuffix}`]: headerValue
|
||||
}
|
||||
);
|
||||
|
||||
const details = {
|
||||
toolName,
|
||||
paramName,
|
||||
bodyValue,
|
||||
headerSuffix,
|
||||
headerValue
|
||||
};
|
||||
|
||||
if (expectation === 'accept') {
|
||||
checks.push(
|
||||
createAcceptanceCheck(
|
||||
checkId,
|
||||
checkName,
|
||||
description,
|
||||
response,
|
||||
SPEC_REFERENCE_BASE64,
|
||||
details
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Custom-header rejection: both 400 and -32001 are MUST per
|
||||
// §Server Behavior for Custom Headers.
|
||||
checks.push(
|
||||
...createRejectionChecks(
|
||||
checkId,
|
||||
checkName,
|
||||
description,
|
||||
response,
|
||||
SPEC_REFERENCE_BASE64,
|
||||
details,
|
||||
{ errorCodeSeverity: 'FAILURE' }
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: checkId,
|
||||
name: checkName,
|
||||
description,
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: [SPEC_REFERENCE_BASE64]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async testMissingCustomHeader(
|
||||
checks: ConformanceCheck[],
|
||||
serverUrl: string,
|
||||
baseHeaders: Record<string, string>,
|
||||
nextId: () => number,
|
||||
toolName: string,
|
||||
paramName: string,
|
||||
headerSuffix: string,
|
||||
defaultArgs: Record<string, any>,
|
||||
defaultHeaders: Record<string, string>
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Send tools/call with value in body but NO Mcp-Param header
|
||||
const response = await sendRawRequest(
|
||||
serverUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: nextId(),
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: { ...defaultArgs, [paramName]: 'test-value' }
|
||||
}
|
||||
},
|
||||
{
|
||||
...baseHeaders,
|
||||
...defaultHeaders,
|
||||
'Mcp-Method': 'tools/call',
|
||||
'Mcp-Name': toolName
|
||||
// Deliberately omit Mcp-Param-{headerSuffix} header
|
||||
}
|
||||
);
|
||||
|
||||
// Custom-header rejection: both 400 and -32001 are MUST.
|
||||
checks.push(
|
||||
...createRejectionChecks(
|
||||
'sep-2243-server-rejects-missing-custom-header',
|
||||
'ServerRejectsMissingCustomHeader',
|
||||
'Server MUST reject request where custom header is omitted but value is present in body',
|
||||
response,
|
||||
SPEC_REFERENCE_CUSTOM,
|
||||
{
|
||||
toolName,
|
||||
paramName,
|
||||
bodyValue: 'test-value',
|
||||
expectedHeader: `Mcp-Param-${headerSuffix}`,
|
||||
mcpParamHeader: '(missing)'
|
||||
},
|
||||
{ errorCodeSeverity: 'FAILURE' }
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
checks.push({
|
||||
id: 'sep-2243-server-rejects-missing-custom-header',
|
||||
name: 'ServerRejectsMissingCustomHeader',
|
||||
description:
|
||||
'Server MUST reject request where custom header is omitted but value is present in body',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
errorMessage: `Failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
specReferences: [SPEC_REFERENCE_CUSTOM]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
59
src/seps/sep-2243.yaml
Normal file
59
src/seps/sep-2243.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
sep: 2243
|
||||
spec_url: https://modelcontextprotocol.io/specification/draft/basic/transports#standard-mcp-request-headers
|
||||
requirements:
|
||||
- check: sep-2243-client-includes-standard-headers
|
||||
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
|
||||
text: 'Clients and servers MUST use case-insensitive comparisons for header names.'
|
||||
- check: sep-2243-server-reject-mismatch
|
||||
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.'
|
||||
- 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
|
||||
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
|
||||
text: 'MCP clients MUST support this feature [custom headers via x-mcp-header].'
|
||||
- check: sep-2243-client-mirrors-designated-params
|
||||
text: 'When a client invokes a tool whose definition includes such designations, conforming clients MUST mirror the designated parameter values into HTTP headers as described below.'
|
||||
- check: sep-2243-x-mcp-header-not-empty
|
||||
text: 'The x-mcp-header value MUST NOT be empty.'
|
||||
url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers
|
||||
- check: sep-2243-x-mcp-header-charset
|
||||
text: 'The x-mcp-header value MUST contain only ASCII characters (excluding space and `:`).'
|
||||
url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers
|
||||
- check: sep-2243-x-mcp-header-unique
|
||||
text: 'The x-mcp-header value MUST be case-insensitively unique within a single tool definition.'
|
||||
url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers
|
||||
- check: sep-2243-x-mcp-header-primitive-only
|
||||
text: 'x-mcp-header MUST only be applied to parameters with primitive types (number, string, or boolean).'
|
||||
url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers
|
||||
- check: sep-2243-client-reject-invalid-tool
|
||||
text: 'Clients MUST reject tool definitions where any x-mcp-header value violates these constraints. Rejection means the client MUST exclude the invalid tool from the set of tools returned by tools/list.'
|
||||
url: https://modelcontextprotocol.io/specification/draft/server/tools#custom-headers
|
||||
- check: sep-2243-client-encode-values
|
||||
text: 'Clients MUST encode parameter values before including them in HTTP headers: number values MUST be converted to their decimal string representation; boolean values MUST be converted to the lowercase strings "true" or "false".'
|
||||
- check: sep-2243-client-base64-unsafe
|
||||
text: 'When a value cannot be safely represented as plain ASCII (e.g., contains non-ASCII characters, control characters, or leading/trailing whitespace), clients MUST use Base64 encoding of the UTF-8 representation, wrapped as =?base64?{encoded}?=.'
|
||||
- check: sep-2243-server-decode-base64
|
||||
text: 'Servers and intermediaries that need to inspect these values MUST decode them accordingly.'
|
||||
- check: sep-2243-client-omit-null
|
||||
text: 'Parameter value is null or omitted: Client MUST omit the header.'
|
||||
- check: sep-2243-server-not-expect-null
|
||||
text: 'Parameter value is null or omitted: Server MUST NOT expect the header.'
|
||||
- check: sep-2243-server-reject-missing-required
|
||||
text: 'Required parameter is omitted: Server MUST reject with JSON-RPC error.'
|
||||
- check: sep-2243-server-reject-invalid-param-chars
|
||||
text: 'Servers MUST reject requests with a recognized Mcp-Param-{Name} header that contain invalid characters.'
|
||||
- check: sep-2243-server-validate-param-match
|
||||
text: 'Any server that processes the message body MUST validate that encoded header values, after decoding if Base64-encoded, match the corresponding parameter values in the body.'
|
||||
- check: sep-2243-server-reject-param-mismatch
|
||||
text: 'Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32001 if any validation fails.'
|
||||
|
||||
- text: 'Clients SHOULD log a warning when rejecting a tool definition due to invalid x-mcp-header, including the tool name and the reason.'
|
||||
excluded: 'Log output is not wire-observable.'
|
||||
- text: 'Server developers SHOULD NOT mark sensitive parameters (such as passwords, API keys, tokens, or PII) with x-mcp-header.'
|
||||
excluded: 'Design guidance to humans; not protocol-observable.'
|
||||
- text: 'Intermediaries MUST return an appropriate HTTP error status for validation failures.'
|
||||
excluded: 'Intermediary requirement; conformance harness tests clients and servers, not intermediaries.'
|
||||
- text: 'Intermediate servers that do not recognize an Mcp-Param-{Name} header MUST forward it and otherwise ignore it.'
|
||||
excluded: 'Intermediary requirement; conformance harness tests clients and servers, not intermediaries.'
|
||||
Loading…
Reference in a new issue