From effb6d66065ffccd0d0b6acbedc370751e497182 Mon Sep 17 00:00:00 2001 From: Christopher Krah <48338417+chrikrah@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:49:33 +0200 Subject: [PATCH 1/3] fix(core): reject a plain Mcp-Param or Mcp-Name value with invalid characters Signed-off-by: Christopher Krah <48338417+chrikrah@users.noreply.github.com> --- .../mcp-param-plain-invalid-characters.md | 6 ++++ .../src/shared/inboundClassification.ts | 2 +- .../src/shared/mcpParamHeaders.ts | 30 +++++++++++-------- .../test/shared/mcpParamHeaders.test.ts | 15 ++++++++++ .../shared/standardHeaderValidation.test.ts | 5 ++++ .../test/server/mcpParamValidation.test.ts | 7 +++++ 6 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 .changeset/mcp-param-plain-invalid-characters.md diff --git a/.changeset/mcp-param-plain-invalid-characters.md b/.changeset/mcp-param-plain-invalid-characters.md new file mode 100644 index 0000000000..f950df4cfe --- /dev/null +++ b/.changeset/mcp-param-plain-invalid-characters.md @@ -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?…?=`. diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index 4f20e8cb41..7fabd46322 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -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 invalid characters or an invalid Base64 sentinel value', 'standard-header-validation' ); } diff --git a/packages/core-internal/src/shared/mcpParamHeaders.ts b/packages/core-internal/src/shared/mcpParamHeaders.ts index 26631c76f7..a0a5afd290 100644 --- a/packages/core-internal/src/shared/mcpParamHeaders.ts +++ b/packages/core-internal/src/shared/mcpParamHeaders.ts @@ -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 { @@ -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; @@ -330,8 +333,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. @@ -374,7 +378,7 @@ export function validateMcpParamHeaders( return paramHeaderMismatchRejection( 'param-header-invalid-encoding', headerKey, - `the ${headerKey} header carries an invalid Base64 sentinel value` + `the ${headerKey} header carries invalid characters or an invalid Base64 sentinel value` ); } // Integer/number-typed declarations compare numerically (the spec's diff --git a/packages/core-internal/test/shared/mcpParamHeaders.test.ts b/packages/core-internal/test/shared/mcpParamHeaders.test.ts index fedad8d36f..406a9d6c08 100644 --- a/packages/core-internal/test/shared/mcpParamHeaders.test.ts +++ b/packages/core-internal/test/shared/mcpParamHeaders.test.ts @@ -301,6 +301,21 @@ describe('validateMcpParamHeaders — server-behavior table', () => { }); }); + 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(); + 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' + }); + 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(); diff --git a/packages/core-internal/test/shared/standardHeaderValidation.test.ts b/packages/core-internal/test/shared/standardHeaderValidation.test.ts index 3cb4e7f893..8eab6ae130 100644 --- a/packages/core-internal/test/shared/standardHeaderValidation.test.ts +++ b/packages/core-internal/test/shared/standardHeaderValidation.test.ts @@ -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', diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 15f78c32eb..0acecfa90d 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -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' }); From abf71d5566b3aab568786792a443d2fe54f252f5 Mon Sep 17 00:00:00 2001 From: Christopher Krah <48338417+chrikrah@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:06:24 +0100 Subject: [PATCH 2/3] fix(core): name the fault when an Mcp-Param or Mcp-Name value is rejected Both rejections read "invalid characters or an invalid Base64 sentinel value", so a client author could not tell a plain value that needed Base64 wrapping from a broken sentinel. The message now names the one that applies; the cell stays *-invalid-encoding for both. Also pin DEL (0x7F) and a C0 control (0x01) as rejected plain values, since the earlier tests only used a non-ASCII character. Signed-off-by: Christopher Krah <48338417+chrikrah@users.noreply.github.com> --- .changeset/mcp-param-plain-invalid-characters.md | 2 +- .../src/shared/inboundClassification.ts | 4 ++-- packages/core-internal/src/shared/mcpParamHeaders.ts | 12 +++++++++++- .../test/shared/mcpParamHeaders.test.ts | 9 +++++++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.changeset/mcp-param-plain-invalid-characters.md b/.changeset/mcp-param-plain-invalid-characters.md index f950df4cfe..9a6a787654 100644 --- a/.changeset/mcp-param-plain-invalid-characters.md +++ b/.changeset/mcp-param-plain-invalid-characters.md @@ -3,4 +3,4 @@ '@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?…?=`. +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. diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index 7fabd46322..e10b2a53bb 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -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'; /* ------------------------------------------------------------------------ * @@ -637,7 +637,7 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout return crossCheckMismatch( 'name-header-invalid-encoding', normalizedNameHeader, - 'the Mcp-Name header carries invalid characters or an invalid Base64 sentinel value', + `the Mcp-Name header carries ${describeMcpParamDecodeFailure(normalizedNameHeader)}`, 'standard-header-validation' ); } diff --git a/packages/core-internal/src/shared/mcpParamHeaders.ts b/packages/core-internal/src/shared/mcpParamHeaders.ts index a0a5afd290..05a0d85e18 100644 --- a/packages/core-internal/src/shared/mcpParamHeaders.ts +++ b/packages/core-internal/src/shared/mcpParamHeaders.ts @@ -287,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) * ------------------------------------------------------------------------ */ @@ -378,7 +388,7 @@ export function validateMcpParamHeaders( return paramHeaderMismatchRejection( 'param-header-invalid-encoding', headerKey, - `the ${headerKey} header carries invalid characters or an invalid Base64 sentinel value` + `the ${headerKey} header carries ${describeMcpParamDecodeFailure(headerValue)}` ); } // Integer/number-typed declarations compare numerically (the spec's diff --git a/packages/core-internal/test/shared/mcpParamHeaders.test.ts b/packages/core-internal/test/shared/mcpParamHeaders.test.ts index 406a9d6c08..8f0721e04e 100644 --- a/packages/core-internal/test/shared/mcpParamHeaders.test.ts +++ b/packages/core-internal/test/shared/mcpParamHeaders.test.ts @@ -297,19 +297,24 @@ 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' + 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' })) From f7476ad48337c8f0fd16ebb8a11abf494b93aac5 Mon Sep 17 00:00:00 2001 From: Christopher Krah <48338417+chrikrah@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:03:39 +0100 Subject: [PATCH 3/3] test(core): pin an unterminated Base64 sentinel to the plain-value path A value that starts with =?base64? but lacks the ?= suffix is not a sentinel, so it decodes as a literal when its characters are plain and is rejected with the plain-value message when they are not. Signed-off-by: Christopher Krah <48338417+chrikrah@users.noreply.github.com> --- .../test/shared/mcpParamHeaders.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/core-internal/test/shared/mcpParamHeaders.test.ts b/packages/core-internal/test/shared/mcpParamHeaders.test.ts index 8f0721e04e..cd4448ccef 100644 --- a/packages/core-internal/test/shared/mcpParamHeaders.test.ts +++ b/packages/core-internal/test/shared/mcpParamHeaders.test.ts @@ -321,6 +321,22 @@ describe('validateMcpParamHeaders — server-behavior table', () => { ).toBeUndefined(); }); + test('an unterminated sentinel is a plain value: accepted as a literal, or rejected as one', () => { + // The sentinel needs both the prefix and the `?=` suffix, so without the suffix the value takes the plain path. + expect(decodeMcpParamValue('=?base64?SGVsbG8=')).toBe('=?base64?SGVsbG8='); + expect(decodeMcpParamValue('=?base64?café')).toBeUndefined(); + const r = validateMcpParamHeaders( + DECLS, + { region: '=?base64?café' }, + new Headers({ [`${MCP_PARAM_HEADER_PREFIX}Region`]: '=?base64?café' }) + ); + expect(r).toMatchObject({ + kind: 'reject', + cell: 'param-header-invalid-encoding', + message: expect.stringContaining('has to be sent Base64-wrapped') + }); + }); + 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();