Summary
validate_standard_headers exempts initialize from SEP-2243 header validation entirely, so a supplied Mcp-Method header that contradicts an initialize body is silently accepted. The exemption's rationale justifies not requiring the headers on initialize, but not ignoring one the client actually sent.
validate_header_matches_init_body, ~250 lines earlier in the same file, already implements the behavior I'd expect here for the sibling header: tolerate absence, reject contradiction.
Current behavior
crates/rmcp/src/transport/streamable_http_server/tower.rs#L730-L752:
/// The `initialize` handshake is exempt: clients emit these headers only after the
/// version has been negotiated.
fn validate_standard_headers(
headers: &HeaderMap,
message: &ClientJsonRpcMessage,
tool_schema: impl Fn(&str) -> Option<Arc<JsonObject>>,
) -> HttpResult<()> {
// ... version gate ...
let request_id = match message {
ClientJsonRpcMessage::Request(req) => {
if matches!(&req.request, ClientRequest::InitializeRequest(_)) {
return Ok(()); // <-- exempt, whether or not a header was sent
}
Some(req.id.clone())
}
// ...
};
So at >= STANDARD_HEADERS, this is accepted:
POST /mcp
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
Every other method rejects the same contradiction via mcp_headers.rs#L271-L280:
let header_method = header_str(headers, HEADER_MCP_METHOD);
match header_method {
None => return Err("missing required Mcp-Method header".to_owned()),
Some(value) if value != method => {
return Err(format!(
"Mcp-Method header `{value}` does not match body method `{method}`"
));
}
Some(_) => {}
}
The inconsistency
On the very same initialize request, the SDK already validates a supplied MCP-Protocol-Version header against the body, and tolerates its absence — tower.rs#L491-L498:
fn validate_header_matches_init_body(
headers: &http::HeaderMap,
body_version: &str,
request_id: Option<RequestId>,
) -> HttpResult<()> {
let Some(header_value) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else {
return Ok(()); // absent: fine
};
// ... present but mismatched: Err
validate_header_matches_init_body is called for InitializeRequest in both the legacy-session path and the stateless path. So the distinction the SDK draws is already "absent is fine, contradictory is not" — Mcp-Method just doesn't get it.
Why this matters outside the SDK
Inside rmcp nothing routes on Mcp-Method, so the forged header is inert and this is not a vulnerability in the SDK itself. The cost lands on the middleboxes SEP-2243 exists for:
Builds and validates the Mcp-Method, Mcp-Name, and Mcp-Param-* headers so middle boxes can route Streamable HTTP traffic without parsing the body.
— crates/rmcp/src/transport/common/mcp_headers.rs
rmcp is the component that makes the header trustworthy, and an intermediary can rely on that for every method except one. A reverse proxy or auth layer that skips body parsing on the strength of Mcp-Method gets a correct answer for tools/call, tools/list, resources/read, notifications — and a header it must independently re-check for initialize.
Concretely, in apollographql/apollo-mcp-server#833 our auth middleware skips token validation for a configured set of anonymous discovery methods. At >= STANDARD_HEADERS it reads Mcp-Method instead of buffering the body, gating on the same raw-header version comparison this function uses so it can never trust a header rmcp would skip. That works for every method except initialize, where we had to plant a compensating check inside the ServerHandler::initialize implementation:
// rmcp 3.3 gap: validate_standard_headers exempts initialize, including
// requests declaring STANDARD_HEADERS or later. Auth can admit those
// requests using Mcp-Method without reading the body, so reject a forged
// discovery header here before initializing the application lifecycle.
if let Some(parts) = context.extensions.get::<http::request::Parts>() {
let mut values = parts.headers.get_all(HEADER_MCP_METHOD).iter();
if let Some(value) = values.next()
&& (value != "initialize" || values.next().is_some())
{
return Err(ErrorData::header_mismatch(
"Mcp-Method must be initialize when supplied for an initialize request",
None,
));
}
}
A transport-level concern implemented in a request handler, reachable only by fishing http::request::Parts out of context.extensions, and silently a no-op if that extension is ever absent on some future path. It cost about 115 lines with its tests.
Proposed fix
In the InitializeRequest arm, check a supplied header before exempting:
let request_id = match message {
ClientJsonRpcMessage::Request(req) => {
if matches!(&req.request, ClientRequest::InitializeRequest(_)) {
// Clients emit SEP-2243 headers only after negotiation, so absence
// is fine — but a supplied header must still agree with the body.
if let Some(value) = header_str(headers, HEADER_MCP_METHOD)
&& value != "initialize"
{
return Err(header_mismatch_jsonrpc_response(
Some(req.id.clone()),
format!("Mcp-Method header `{value}` does not match body method `initialize`"),
)
.into());
}
return Ok(());
}
Some(req.id.clone())
}
// ...
};
This keeps the exemption's intent (headers stay optional through the handshake) and closes the one case where a present header is ignored. Mcp-Name and Mcp-Param-* don't apply to initialize, so Mcp-Method is the only one that needs this.
Happy to open a PR if the approach looks right.
Environment
- rmcp
main @ b037c0fa886158fd9b09b915df866458688967e4, also reproduces on the published 3.3.0
- Affects
server-side-http / the streamable HTTP tower service, both legacy-session and stateless paths
Summary
validate_standard_headersexemptsinitializefrom SEP-2243 header validation entirely, so a suppliedMcp-Methodheader that contradicts aninitializebody is silently accepted. The exemption's rationale justifies not requiring the headers oninitialize, but not ignoring one the client actually sent.validate_header_matches_init_body, ~250 lines earlier in the same file, already implements the behavior I'd expect here for the sibling header: tolerate absence, reject contradiction.Current behavior
crates/rmcp/src/transport/streamable_http_server/tower.rs#L730-L752:So at
>= STANDARD_HEADERS, this is accepted:Every other method rejects the same contradiction via
mcp_headers.rs#L271-L280:The inconsistency
On the very same
initializerequest, the SDK already validates a suppliedMCP-Protocol-Versionheader against the body, and tolerates its absence —tower.rs#L491-L498:validate_header_matches_init_bodyis called forInitializeRequestin both the legacy-session path and the stateless path. So the distinction the SDK draws is already "absent is fine, contradictory is not" —Mcp-Methodjust doesn't get it.Why this matters outside the SDK
Inside rmcp nothing routes on
Mcp-Method, so the forged header is inert and this is not a vulnerability in the SDK itself. The cost lands on the middleboxes SEP-2243 exists for:rmcp is the component that makes the header trustworthy, and an intermediary can rely on that for every method except one. A reverse proxy or auth layer that skips body parsing on the strength of
Mcp-Methodgets a correct answer fortools/call,tools/list,resources/read, notifications — and a header it must independently re-check forinitialize.Concretely, in apollographql/apollo-mcp-server#833 our auth middleware skips token validation for a configured set of anonymous discovery methods. At
>= STANDARD_HEADERSit readsMcp-Methodinstead of buffering the body, gating on the same raw-header version comparison this function uses so it can never trust a header rmcp would skip. That works for every method exceptinitialize, where we had to plant a compensating check inside theServerHandler::initializeimplementation:A transport-level concern implemented in a request handler, reachable only by fishing
http::request::Partsout ofcontext.extensions, and silently a no-op if that extension is ever absent on some future path. It cost about 115 lines with its tests.Proposed fix
In the
InitializeRequestarm, check a supplied header before exempting:This keeps the exemption's intent (headers stay optional through the handshake) and closes the one case where a present header is ignored.
Mcp-NameandMcp-Param-*don't apply toinitialize, soMcp-Methodis the only one that needs this.Happy to open a PR if the approach looks right.
Environment
main@b037c0fa886158fd9b09b915df866458688967e4, also reproduces on the published 3.3.0server-side-http/ the streamable HTTP tower service, both legacy-session and stateless paths