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

* Add prepare script for git dependency installs

* Add conformance tests for SEP-2243 HTTP Standardization

* Add traceability file for SEP-2243

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

* Improve SEP-2243 conformance test coverage

Close gaps in HTTP header conformance scenarios:

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

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

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

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

* Fill SEP-2243 conformance test coverage gaps

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

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

Register all new checks in sep-2243.yaml.

* Remove case-insensitive =?base64? prefix test

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

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

* Remove nested x-mcp-header rejection test

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

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

* Use numeric comparison for number header values

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

See SEP-2243 discussion on number precision.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* add negative test for HttpStandardHeadersScenario

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

View file

@ -0,0 +1,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]
});
}
}
}