Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mcp-param-plain-invalid-characters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/server': patch
---

A server on a 2026-07-28 connection now rejects an `Mcp-Param-*` or `Mcp-Name` header whose plain (not Base64-wrapped) value holds a character outside visible ASCII, space and tab, with `400` and JSON-RPC error `-32020`, as the spec's "Servers MUST reject requests with a recognized `Mcp-Param-{Name}` header that contains invalid characters" requires. Such a value was accepted when it equalled the body; conforming clients, this SDK's included, already send it as `=?base64?…?=`. The error message names which fault it is: a plain value that needed Base64 wrapping, or a broken sentinel.
4 changes: 2 additions & 2 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelope
// `HeaderMismatch` constant and rejection type from here, so this is a benign
// two-module cycle (both sides only consume the other's exports inside
// function bodies, never at module-evaluation time).
import { decodeMcpParamValue } from './mcpParamHeaders';
import { decodeMcpParamValue, describeMcpParamDecodeFailure } from './mcpParamHeaders';
import { isModernProtocolVersion } from './protocolEras';

/* ------------------------------------------------------------------------ *
Expand Down Expand Up @@ -637,7 +637,7 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
return crossCheckMismatch(
'name-header-invalid-encoding',
normalizedNameHeader,
'the Mcp-Name header carries an invalid Base64 sentinel value',
`the Mcp-Name header carries ${describeMcpParamDecodeFailure(normalizedNameHeader)}`,
'standard-header-validation'
);
}
Expand Down
40 changes: 27 additions & 13 deletions packages/core-internal/src/shared/mcpParamHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,16 +229,18 @@ export function mcpParamPrimitiveToString(value: unknown): string | undefined {
function needsBase64(s: string): boolean {
if (s.length === 0) return true;
if (s.startsWith(BASE64_SENTINEL_PREFIX) && s.endsWith(BASE64_SENTINEL_SUFFIX)) return true;
// Field parsing strips leading/trailing whitespace, a tab included.
if (s !== s.trim()) return true;
return !hasOnlyPlainFieldChars(s);
}

/** `true` when every character is visible ASCII `0x21–0x7E`, space `0x20` or horizontal tab `0x09`. */
function hasOnlyPlainFieldChars(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.codePointAt(i)!;
// Visible ASCII 0x21–0x7E, plus space 0x20 and horizontal tab 0x09; a
// tab is only safe when it is interior whitespace (the trim() check
// above already covered leading/trailing).
if (c === 0x09 || (c >= 0x20 && c <= 0x7e)) continue;
return true;
if (c !== 0x09 && (c < 0x20 || c > 0x7e)) return false;
}
return false;
return true;
}

function utf8ToBase64(s: string): string {
Expand Down Expand Up @@ -267,13 +269,14 @@ export function encodeMcpParamValue(value: string): string {
/**
* Decode an `Mcp-Param-*` header value: when it carries the Base64 sentinel,
* the payload is decoded as UTF-8; otherwise the value is returned as-is.
* Returns `undefined` when the sentinel is present but the payload is not
* canonical Base64 (or not valid UTF-8) — the spec requires servers to reject
* such values.
* Returns `undefined` when a plain value holds a character outside
* `0x20–0x7E` / `0x09` (such a value has to arrive Base64-wrapped), or when the
* sentinel is present but the payload is not canonical Base64 (or not valid
* UTF-8) — the spec requires servers to reject such values.
*/
export function decodeMcpParamValue(value: string): string | undefined {
if (!(value.startsWith(BASE64_SENTINEL_PREFIX) && value.endsWith(BASE64_SENTINEL_SUFFIX))) {
return value;
return hasOnlyPlainFieldChars(value) ? value : undefined;
}
const b64 = value.slice(BASE64_SENTINEL_PREFIX.length, value.length - BASE64_SENTINEL_SUFFIX.length);
if (!BASE64_CANONICAL.test(b64)) return undefined;
Expand All @@ -284,6 +287,16 @@ export function decodeMcpParamValue(value: string): string | undefined {
}
}

/**
* Why {@linkcode decodeMcpParamValue} returned `undefined` for `value`, worded for an error message, so
* that a client author can tell a plain value that needed Base64 wrapping from a broken sentinel.
*/
export function describeMcpParamDecodeFailure(value: string): string {
return value.startsWith(BASE64_SENTINEL_PREFIX) && value.endsWith(BASE64_SENTINEL_SUFFIX)
? 'an invalid Base64 sentinel value'
: 'a plain value with a character outside 0x20-0x7E / 0x09, which has to be sent Base64-wrapped';
}

/* ------------------------------------------------------------------------ *
* Client-side header construction (the 5-step MUST algorithm, steps 3–5)
* ------------------------------------------------------------------------ */
Expand Down Expand Up @@ -330,8 +343,9 @@ export function buildMcpParamHeaders(
* `arguments` carries a value, the matching `Mcp-Param-{Name}` header MUST be
* present and decode to an equal value; when the body value is `null` or
* absent the server MUST NOT expect the header (a present header is ignored).
* A sentinel-carrying header whose payload is not canonical Base64 / valid
* UTF-8 is rejected as invalid characters.
* A plain header holding a character outside `0x20–0x7E` / `0x09`, or a
* sentinel-carrying header whose payload is not canonical Base64 / valid
* UTF-8, is rejected as invalid characters.
*
* Integer- and number-typed declarations are compared numerically (the spec's SHOULD —
* `42.0` and `42` are equal); everything else is compared as decoded strings.
Expand Down Expand Up @@ -374,7 +388,7 @@ export function validateMcpParamHeaders(
return paramHeaderMismatchRejection(
'param-header-invalid-encoding',
headerKey,
`the ${headerKey} header carries an invalid Base64 sentinel value`
`the ${headerKey} header carries ${describeMcpParamDecodeFailure(headerValue)}`
);
}
// Integer/number-typed declarations compare numerically (the spec's
Expand Down
22 changes: 21 additions & 1 deletion packages/core-internal/test/shared/mcpParamHeaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,30 @@ describe('validateMcpParamHeaders — server-behavior table', () => {
kind: 'reject',
httpStatus: 400,
code: HEADER_MISMATCH_ERROR_CODE,
cell: 'param-header-invalid-encoding'
cell: 'param-header-invalid-encoding',
message: expect.stringContaining('carries an invalid Base64 sentinel value')
});
});

test('a plain header with a character outside 0x20-0x7E / 0x09 → reject 400/-32020, even when it equals the body', () => {
// Node's HTTP parser hands a raw 0xE9 byte through as 'é'; a conforming client Base64-wraps it.
expect(decodeMcpParamValue('café')).toBeUndefined();
// DEL and a C0 control are outside the range too, not only non-ASCII.
expect(decodeMcpParamValue('a\x7fb')).toBeUndefined();
expect(decodeMcpParamValue('a\x01b')).toBeUndefined();
const r = validateMcpParamHeaders(DECLS, { region: 'café' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'café' }));
expect(r).toMatchObject({
kind: 'reject',
httpStatus: 400,
code: HEADER_MISMATCH_ERROR_CODE,
cell: 'param-header-invalid-encoding',
message: expect.stringContaining('has to be sent Base64-wrapped')
});
expect(
validateMcpParamHeaders(DECLS, { region: 'a\tb' }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: 'a\tb' }))
).toBeUndefined();
});

test('integer-typed declarations are compared numerically (42.0 == 42)', () => {
const intDecl = [{ path: ['n'], headerName: 'N', type: 'integer' }] as const;
expect(validateMcpParamHeaders(intDecl, { n: 42 }, new Headers({ [`${MCP_PARAM_HEADER_PREFIX}N`]: '42.0' }))).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check
expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding');
});

test('a plain Mcp-Name with a character outside 0x20-0x7E / 0x09 is rejected (name-header-invalid-encoding)', () => {
const { request, route } = modernPost('tools/call', { name: 'café', arguments: {} }, { mcpMethod: 'tools/call', mcpName: 'café' });
expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding');
});

test('raw HTTP OWS is stripped from standard MCP header values', () => {
const { request } = modernPost(
'tools/call',
Expand Down
7 changes: 7 additions & 0 deletions packages/server/test/server/mcpParamValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)'
expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020);
});

it('a plain header carrying a raw non-ASCII value is rejected 400/-32020 even when it equals the body', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(call({ region: 'café' }, { 'Mcp-Param-Region': 'café' }));
expect(response.status).toBe(400);
expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020);
});

it('a number-typed declaration does not switch the check off: its header and a valid sibling header are still compared', async () => {
const handler = createMcpHandler(() => {
const s = new McpServer({ name: 'param-server', version: '1.0.0' });
Expand Down
Loading