Summary
SEP-2243 header validation reads each header with HeaderMap::get, which returns the first of several field lines with the same name. A request carrying two contradictory Mcp-Method values is validated against the first and the second is silently discarded, rather than the ambiguity being rejected.
The same applies to Mcp-Name and every Mcp-Param-* header — all three go through one helper, and there is no get_all anywhere in the module.
Current behavior
crates/rmcp/src/transport/common/mcp_headers.rs#L334-L336:
fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Option<&'a str> {
headers.get(name).and_then(|value| value.to_str().ok())
}
Its three call sites cover the whole SEP-2243 surface:
271: let header_method = header_str(headers, HEADER_MCP_METHOD);
283: match header_str(headers, HEADER_MCP_NAME) {
303: let header_value = header_str(headers, &full);
So at >= STANDARD_HEADERS this passes validation:
POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
Mcp-Method: tools/call
{"jsonrpc":"2.0","id":1,"method":"tools/list", ...}
Mcp-Method carries one method name, not a comma-separated list, so two field lines are not a value rmcp should be picking a winner from.
Why this matters
The stated purpose of these headers is intermediary routing, from the module's own docs:
Builds and validates the Mcp-Method, Mcp-Name, and Mcp-Param-* headers so middle boxes can route Streamable HTTP traffic without parsing the body.
That only holds if every hop resolves a duplicated header the same way — and nothing guarantees that. An intermediary that reads the last occurrence, joins the values, or iterates get_all will route on tools/call while rmcp validates and dispatches tools/list. The two ends disagree about what the request is, which is the classic proxy/origin desync shape, and validation makes it worse rather than better: the intermediary believes the SDK has confirmed "the" header matches the body, when in fact they read different ones.
This is what we hit in apollographql/apollo-mcp-server#833. Our auth middleware skips token validation for configured anonymous discovery methods, reading Mcp-Method instead of buffering the body once the declared protocol version is one rmcp validates headers for. Because the SDK resolves duplicates to the first value, we could not simply read the header the same way — we had to reject the ambiguity ourselves:
let mut values = request.headers().get_all(HEADER_MCP_METHOD).iter();
let method = values.next().and_then(|value| value.to_str().ok());
let Some(method) = method.filter(|_| values.next().is_none()) else {
// 401: ambiguous Mcp-Method
};
Every intermediary that wants to trust these headers has to write that, and each one that forgets is silently inconsistent with the SDK.
Worth noting this costs conforming clients nothing: build_request_headers pushes exactly one value per header name, so no rmcp client can produce this shape.
Proposed fix
Reject the ambiguity in header_str rather than at each call site:
/// The sole value for `name`, or `Err` when it appears more than once.
///
/// These headers are singletons — a second field line is not a value to pick a
/// winner from, and letting one through would let an intermediary that resolves
/// duplicates differently disagree with the body this request dispatches.
fn header_str<'a>(headers: &'a http::HeaderMap, name: &str) -> Result<Option<&'a str>, String> {
let mut values = headers.get_all(name).iter();
let Some(first) = values.next() else {
return Ok(None);
};
if values.next().is_some() {
return Err(format!("duplicate {name} header"));
}
Ok(first.to_str().ok())
}
One subtlety worth flagging
The tempting one-line version — keep the Option signature and return None on duplicates — is fail-closed for Mcp-Method and Mcp-Name (both treat None as a missing-required-header error), so it would look sufficient. It is not, because of the Mcp-Param-* arm at mcp_headers.rs#L307-L313:
match (header_value, body_value) {
(None, None) => {}
(Some(_), None) => {
return Err(format!("unexpected {full} header for absent or null `{prop}`"));
}
With a None-on-duplicate helper, two contradictory Mcp-Param-X headers whose argument is absent or null collapse to (None, None) and are accepted — the one case where the ambiguity would survive. Distinguishing "absent" from "ambiguous" avoids that, and gives a clearer message than "missing required Mcp-Method header" for a request that plainly sent one.
Happy to open a PR.
Environment
- rmcp
main @ b037c0fa886158fd9b09b915df866458688967e4, also reproduces on the published 3.3.0
- Affects
server-side-http header validation; the client-side builder is unaffected
Related: #1271 (the other place a supplied SEP-2243 header is not checked against the body).
Summary
SEP-2243 header validation reads each header with
HeaderMap::get, which returns the first of several field lines with the same name. A request carrying two contradictoryMcp-Methodvalues is validated against the first and the second is silently discarded, rather than the ambiguity being rejected.The same applies to
Mcp-Nameand everyMcp-Param-*header — all three go through one helper, and there is noget_allanywhere in the module.Current behavior
crates/rmcp/src/transport/common/mcp_headers.rs#L334-L336:Its three call sites cover the whole SEP-2243 surface:
So at
>= STANDARD_HEADERSthis passes validation:Mcp-Methodcarries one method name, not a comma-separated list, so two field lines are not a value rmcp should be picking a winner from.Why this matters
The stated purpose of these headers is intermediary routing, from the module's own docs:
That only holds if every hop resolves a duplicated header the same way — and nothing guarantees that. An intermediary that reads the last occurrence, joins the values, or iterates
get_allwill route ontools/callwhile rmcp validates and dispatchestools/list. The two ends disagree about what the request is, which is the classic proxy/origin desync shape, and validation makes it worse rather than better: the intermediary believes the SDK has confirmed "the" header matches the body, when in fact they read different ones.This is what we hit in apollographql/apollo-mcp-server#833. Our auth middleware skips token validation for configured anonymous discovery methods, reading
Mcp-Methodinstead of buffering the body once the declared protocol version is one rmcp validates headers for. Because the SDK resolves duplicates to the first value, we could not simply read the header the same way — we had to reject the ambiguity ourselves:Every intermediary that wants to trust these headers has to write that, and each one that forgets is silently inconsistent with the SDK.
Worth noting this costs conforming clients nothing:
build_request_headerspushes exactly one value per header name, so no rmcp client can produce this shape.Proposed fix
Reject the ambiguity in
header_strrather than at each call site:One subtlety worth flagging
The tempting one-line version — keep the
Optionsignature and returnNoneon duplicates — is fail-closed forMcp-MethodandMcp-Name(both treatNoneas a missing-required-header error), so it would look sufficient. It is not, because of theMcp-Param-*arm atmcp_headers.rs#L307-L313:With a
None-on-duplicate helper, two contradictoryMcp-Param-Xheaders whose argument is absent or null collapse to(None, None)and are accepted — the one case where the ambiguity would survive. Distinguishing "absent" from "ambiguous" avoids that, and gives a clearer message than "missing required Mcp-Method header" for a request that plainly sent one.Happy to open a PR.
Environment
main@b037c0fa886158fd9b09b915df866458688967e4, also reproduces on the published 3.3.0server-side-httpheader validation; the client-side builder is unaffectedRelated: #1271 (the other place a supplied SEP-2243 header is not checked against the body).