diff --git a/.changeset/mcp-param-plain-invalid-characters.md b/.changeset/mcp-param-plain-invalid-characters.md new file mode 100644 index 0000000000..9a6a787654 --- /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?…?=`. 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 4f20e8cb41..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 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 26631c76f7..05a0d85e18 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; @@ -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) * ------------------------------------------------------------------------ */ @@ -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. @@ -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 diff --git a/packages/core-internal/test/shared/mcpParamHeaders.test.ts b/packages/core-internal/test/shared/mcpParamHeaders.test.ts index fedad8d36f..cd4448ccef 100644 --- a/packages/core-internal/test/shared/mcpParamHeaders.test.ts +++ b/packages/core-internal/test/shared/mcpParamHeaders.test.ts @@ -297,7 +297,43 @@ 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('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') }); }); 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' });