Skip to content

Commit 0ab5d14

Browse files
authored
fix(server): trim OWS from standard MCP headers (#2453)
1 parent 7e69735 commit 0ab5d14

3 files changed

Lines changed: 85 additions & 3 deletions

File tree

.changeset/standard-header-ows.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
'@modelcontextprotocol/core-internal': patch
4+
---
5+
6+
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()`.

packages/core-internal/src/shared/inboundClassification.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,25 @@ export const MCP_NAME_HEADER_SOURCE: Readonly<Record<string, 'name' | 'uri'>> =
464464
'resources/read': 'uri'
465465
};
466466

467+
/** Strip RFC 9110 optional whitespace (SP / HTAB) around a field value in linear time. */
468+
function stripHttpOws(value: string): string {
469+
let start = 0;
470+
while (start < value.length) {
471+
const code = value.codePointAt(start);
472+
if (code !== 0x09 && code !== 0x20) break;
473+
start += 1;
474+
}
475+
476+
let end = value.length;
477+
while (end > start) {
478+
const code = value.codePointAt(end - 1);
479+
if (code !== 0x09 && code !== 0x20) break;
480+
end -= 1;
481+
}
482+
483+
return start === 0 && end === value.length ? value : value.slice(start, end);
484+
}
485+
467486
/**
468487
* SEP-2243 standard-header server-side validation, evaluated by the HTTP
469488
* entry on a modern-classified request immediately after
@@ -537,19 +556,20 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
537556
);
538557
}
539558

540-
const decoded = decodeMcpParamValue(request.mcpNameHeader);
559+
const normalizedNameHeader = stripHttpOws(request.mcpNameHeader);
560+
const decoded = decodeMcpParamValue(normalizedNameHeader);
541561
if (decoded === undefined) {
542562
return crossCheckMismatch(
543563
'name-header-invalid-encoding',
544-
request.mcpNameHeader,
564+
normalizedNameHeader,
545565
'the Mcp-Name header carries an invalid Base64 sentinel value',
546566
'standard-header-validation'
547567
);
548568
}
549569
if (bodyValue !== undefined && decoded !== bodyValue) {
550570
return crossCheckMismatch(
551571
'name-header-mismatch',
552-
request.mcpNameHeader,
572+
normalizedNameHeader,
553573
`the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`,
554574
'standard-header-validation'
555575
);
@@ -818,6 +838,18 @@ function classifyNotificationBody(request: InboundHttpRequest, body: JSONRPCNoti
818838
* `modern`) or a ladder rejection; it never throws.
819839
*/
820840
export function classifyInboundRequest(request: InboundHttpRequest): InboundClassificationOutcome {
841+
// RFC 9110 §5.5: field parsing excludes optional whitespace around a
842+
// field value. Fetch implementations normally perform this normalization,
843+
// but transport-neutral callers and some runtimes can expose raw OWS.
844+
request = {
845+
...request,
846+
...(request.protocolVersionHeader !== undefined && {
847+
protocolVersionHeader: stripHttpOws(request.protocolVersionHeader)
848+
}),
849+
...(request.mcpMethodHeader !== undefined && { mcpMethodHeader: stripHttpOws(request.mcpMethodHeader) }),
850+
...(request.mcpNameHeader !== undefined && { mcpNameHeader: stripHttpOws(request.mcpNameHeader) })
851+
};
852+
821853
if (request.httpMethod.toUpperCase() !== 'POST') {
822854
// Body-less 2025-era session operations (and any other non-POST
823855
// method): the modern era is POST-only.

packages/core-internal/test/shared/standardHeaderValidation.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,50 @@ describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check
157157
expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding');
158158
});
159159

160+
test('raw HTTP OWS is stripped from standard MCP header values', () => {
161+
const { request } = modernPost(
162+
'tools/call',
163+
{ name: 'echo', arguments: {} },
164+
{ mcpMethod: '\t tools/call ', mcpName: ' echo \t' }
165+
);
166+
request.protocolVersionHeader = ` ${MODERN}\t`;
167+
const classified = classifyInboundRequest(request);
168+
expect(classified.kind).toBe('modern');
169+
if (classified.kind !== 'modern') {
170+
throw new Error(`expected a modern route, got ${classified.kind}`);
171+
}
172+
expect(validateStandardRequestHeaders(request, classified)).toBeUndefined();
173+
});
174+
175+
test('long OWS runs are stripped without changing an encoded name', () => {
176+
const ows = '\t '.repeat(50_000);
177+
const name = ' echo ';
178+
const { request } = modernPost(
179+
'tools/call',
180+
{ name, arguments: {} },
181+
{
182+
mcpMethod: `${ows}tools/call${ows}`,
183+
mcpName: `${ows}${encodeMcpParamValue(name)}${ows}`
184+
}
185+
);
186+
request.protocolVersionHeader = `${ows}${MODERN}${ows}`;
187+
188+
const classified = classifyInboundRequest(request);
189+
expect(classified.kind).toBe('modern');
190+
if (classified.kind !== 'modern') {
191+
throw new Error(`expected a modern route, got ${classified.kind}`);
192+
}
193+
expect(validateStandardRequestHeaders(request, classified)).toBeUndefined();
194+
});
195+
196+
test('whitespace outside RFC 9110 OWS is not stripped', () => {
197+
const { request } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call', mcpName: 'echo' });
198+
request.mcpMethodHeader = '\u00a0tools/call';
199+
const classified = classifyInboundRequest(request);
200+
expect(classified.kind).toBe('reject');
201+
expect((classified as InboundLadderRejection).cell).toBe('method-header-mismatch');
202+
});
203+
160204
test('a matching Mcp-Name on a prompts/get passes', () => {
161205
const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' });
162206
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();

0 commit comments

Comments
 (0)