Skip to content

streamable-http server: a supplied Mcp-Method contradicting an initialize body is silently accepted #1271

Description

@DaleSeo

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High: significant functionality gap or spec violationT-transportTransport layer changesbugSomething is not workingready for workIssue is well-defined and ready to be picked up

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions