Skip to content

streamable-http server: duplicate SEP-2243 headers silently resolve to the first value #1272

Description

@DaleSeo

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).

Activity

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

Metadata

Metadata

Assignees

Labels

P0Critical: blocking, security, data loss, or crashT-securitySecurity-related changesT-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