modelcontextprotocol / modelcontextprotocol/rust-sdk
streamable-http server: a supplied Mcp-Method contradicting an initialize body is silently accepted
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 3.9k
- Forks
- 645
- Avg merge
- 4d 13h
- Merged PRs (30d)
- 36
Description
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, andMcp-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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in crates/rmcp/src/transport/streamable_http_server/tower.rs by reading validate_standard_headers and validate_header_matches_init_body, then compare the related logic in crates/rmcp/src/transport/common/mcp_headers.rs. Verify that initialize still accepts an absent Mcp-Method but rejects a supplied value other than initialize, while existing validation for other methods remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100