fix(server): trim OWS from standard MCP headers (#2453)

This commit is contained in:
Matt 2026-07-07 14:22:45 +01:00 • committed by GitHub
commit 0ab5d1471d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 85 additions and 3 deletions

View file

@ -0,0 +1,6 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/core-internal': patch
---
Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`.

View file

@ -464,6 +464,25 @@ export const MCP_NAME_HEADER_SOURCE: Readonly<Record<string, 'name' | 'uri'>> =
'resources/read': 'uri'
};
/** Strip RFC 9110 optional whitespace (SP / HTAB) around a field value in linear time. */
function stripHttpOws(value: string): string {
let start = 0;
while (start < value.length) {
const code = value.codePointAt(start);
if (code !== 0x09 && code !== 0x20) break;
start += 1;
}
let end = value.length;
while (end > start) {
const code = value.codePointAt(end - 1);
if (code !== 0x09 && code !== 0x20) break;
end -= 1;
}
return start === 0 && end === value.length ? value : value.slice(start, end);
}
/**
* SEP-2243 standard-header server-side validation, evaluated by the HTTP
* entry on a modern-classified request immediately after
@ -537,11 +556,12 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
);
}
const decoded = decodeMcpParamValue(request.mcpNameHeader);
const normalizedNameHeader = stripHttpOws(request.mcpNameHeader);
const decoded = decodeMcpParamValue(normalizedNameHeader);
if (decoded === undefined) {
return crossCheckMismatch(
'name-header-invalid-encoding',
request.mcpNameHeader,
normalizedNameHeader,
'the Mcp-Name header carries an invalid Base64 sentinel value',
'standard-header-validation'
);
@ -549,7 +569,7 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
if (bodyValue !== undefined && decoded !== bodyValue) {
return crossCheckMismatch(
'name-header-mismatch',
request.mcpNameHeader,
normalizedNameHeader,
`the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`,
'standard-header-validation'
);
@ -818,6 +838,18 @@ function classifyNotificationBody(request: InboundHttpRequest, body: JSONRPCNoti
* `modern`) or a ladder rejection; it never throws.
*/
export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome {
// RFC 9110 §5.5: field parsing excludes optional whitespace around a
// field value. Fetch implementations normally perform this normalization,
// but transport-neutral callers and some runtimes can expose raw OWS.
request = {
...request,
...(request.protocolVersionHeader !== undefined && {
protocolVersionHeader: stripHttpOws(request.protocolVersionHeader)
}),
...(request.mcpMethodHeader !== undefined && { mcpMethodHeader: stripHttpOws(request.mcpMethodHeader) }),
...(request.mcpNameHeader !== undefined && { mcpNameHeader: stripHttpOws(request.mcpNameHeader) })
};
if (request.httpMethod.toUpperCase() !== 'POST') {
// Body-less 2025-era session operations (and any other non-POST
// method): the modern era is POST-only.

View file

@ -157,6 +157,50 @@ describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check
expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding');
});
test('raw HTTP OWS is stripped from standard MCP header values', () => {
const { request } = modernPost(
'tools/call',
{ name: 'echo', arguments: {} },
{ mcpMethod: '\t tools/call ', mcpName: ' echo \t' }
);
request.protocolVersionHeader = ` ${MODERN}\t`;
const classified = classifyInboundRequest(request);
expect(classified.kind).toBe('modern');
if (classified.kind !== 'modern') {
throw new Error(`expected a modern route, got ${classified.kind}`);
}
expect(validateStandardRequestHeaders(request, classified)).toBeUndefined();
});
test('long OWS runs are stripped without changing an encoded name', () => {
const ows = '\t '.repeat(50_000);
const name = ' echo ';
const { request } = modernPost(
'tools/call',
{ name, arguments: {} },
{
mcpMethod: `${ows}tools/call${ows}`,
mcpName: `${ows}${encodeMcpParamValue(name)}${ows}`
}
);
request.protocolVersionHeader = `${ows}${MODERN}${ows}`;
const classified = classifyInboundRequest(request);
expect(classified.kind).toBe('modern');
if (classified.kind !== 'modern') {
throw new Error(`expected a modern route, got ${classified.kind}`);
}
expect(validateStandardRequestHeaders(request, classified)).toBeUndefined();
});
test('whitespace outside RFC 9110 OWS is not stripped', () => {
const { request } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call', mcpName: 'echo' });
request.mcpMethodHeader = '\u00a0tools/call';
const classified = classifyInboundRequest(request);
expect(classified.kind).toBe('reject');
expect((classified as InboundLadderRejection).cell).toBe('method-header-mismatch');
});
test('a matching Mcp-Name on a prompts/get passes', () => {
const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' });
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();