modelcontextprotocol / modelcontextprotocol/rust-sdk

Stateless streamable-HTTP logs a placeholder client_info ("rmcp") instead of the real client identity

Open
#1,172 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

P2
Dominant language
Rust
Stars
3.9k
Forks
645
Avg merge
4d 13h
Merged PRs (30d)
36

Description

Summary

In stateless streamable-HTTP mode (StreamableHttpServerConfig::legacy_session_mode = false), every request — including the real initialize handshake — gets a Peer::peer_info() whose client_info is always Implementation::default() (i.e. {"name": "rmcp", "version": "<crate version>"}), never the client's real clientInfo. This value is then logged by serve_inner's tracing::info!(?peer_info, "Service initialized as server") (service.rs#L1342) as if it were the negotiated peer identity, which is misleading for observability/debugging — logs show every distinct client (Claude Code, Gemini CLI, our own web app, etc.) as "rmcp"/"<sdk version>", making it impossible to tell which real client connected from server logs alone.

Root cause

peer_info_for_stateless_request in crates/rmcp/src/transport/streamable_http_server/tower.rs:

fn peer_info_for_stateless_request(
    request: &crate::model::JsonRpcRequest<ClientRequest>,
    headers: &HeaderMap,
) -> Option<InitializeRequestParams> {
    let version = if let ClientRequest::InitializeRequest(ref init) = request.request {
        init.params.protocol_version.clone()
    } else {
        headers
            .get(HEADER_MCP_PROTOCOL_VERSION)
            .and_then(|v| v.to_str().ok())
            .and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok())
            .unwrap_or(ProtocolVersion::V_2025_03_26)
    };
    Some(InitializeRequestParams {
        meta: None,
        protocol_version: version,             // reconstructed correctly
        capabilities: ClientCapabilities::default(),   // always blank
        client_info: Implementation::default(),        // always "rmcp"/crate version
    })
}

Only protocol_version is reconstructed from real data (the request body for initialize, or the MCP-Protocol-Version header otherwise). client_info and capabilities are always the blank/default placeholder — even for the literal initialize request, whose body does contain the real clientInfo, but that value is never threaded into the synthesized peer_info.

This placeholder feeds into serve_directly_with_ct (service.rs#L1281-1297):

pub fn serve_directly_with_ct<R, S, T, E, A>(service: S, transport: T, peer_info: Option<R::PeerInfo>, ct: ...) -> RunningService<R, S> {
    let (peer, peer_rx) = Peer::new(..., peer_info);   // <- placeholder from above
    R::configure_direct_peer(&peer, &service.get_info());
    serve_inner(service, transport.into_transport(), peer, peer_rx, ct)
}

serve_inner (service.rs#L1338-1343) then logs it immediately and synchronously, before the request is actually dispatched to the handler:

let peer_info = peer.peer_info();
if R::IS_CLIENT {
    tracing::info!(?peer_info, "Service initialized as client");
} else {
    tracing::info!(?peer_info, "Service initialized as server");
}

So even a handler that overrides initialize and calls context.peer.set_peer_info(request.clone()) with the real clientInfo (the default ServerHandler::initialize impl does exactly this, handler/server.rs#L318-330) updates the Peer too late — the log line has already fired with the placeholder, because serve_inner is a plain (non-async) function that samples peer.peer_info() synchronously right after Peer::new, before any request processing task runs.

Reproduction (self-contained test, no external client needed)

use std::sync::{Arc, Mutex};
use axum::{Router, body::Body};
use http::{Request, StatusCode};
use rmcp::ServerHandler;
use rmcp::model::{Implementation, InitializeRequestParams, ServerInfo};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::transport::streamable_http_server::{
    StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use tower::ServiceExt;

#[derive(Debug, Clone)]
struct Observed {
    from_request_arg: Implementation,
    from_peer_snapshot: Option<Implementation>,
}

#[derive(Clone, Default)]
struct ProbeHandler {
    observed: Arc<Mutex<Option<Observed>>>,
}

impl ServerHandler for ProbeHandler {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::default()
    }

    async fn initialize(
        &self,
        request: InitializeRequestParams,
        context: RequestContext<RoleServer>,
    ) -> Result<ServerInfo, rmcp::ErrorData> {
        *self.observed.lock().unwrap() = Some(Observed {
            from_request_arg: request.client_info.clone(),
            from_peer_snapshot: context.peer.peer_info().map(|p| p.client_info.clone()),
        });
        Ok(self.get_info())
    }
}

async fn send_initialize_and_observe(legacy_session_mode: bool) -> Observed {
    let observed = Arc::new(Mutex::new(None));
    let handler = ProbeHandler { observed: observed.clone() };

    let mut config = StreamableHttpServerConfig::default();
    config.legacy_session_mode = legacy_session_mode;

    let service = StreamableHttpService::new(
        move || Ok(handler.clone()),
        LocalSessionManager::default().into(),
        config,
    );
    let app = Router::new().nest_service("/mcp", service);

    let body = serde_json::json!({
        "jsonrpc": "2.0", "id": 1, "method": "initialize",
        "params": {
            "protocolVersion": "2025-06-18",
            "capabilities": {},
            "clientInfo": { "name": "totally-real-client", "version": "42.0.0" }
        }
    });

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/mcp")
                .header("host", "127.0.0.1:3000")
                .header("content-type", "application/json")
                .header("accept", "application/json, text/event-stream")
                .body(Body::from(body.to_string()))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    let _ = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
    observed.lock().unwrap().clone().expect("handler did not run")
}

#[tokio::test]
async fn stateless_mode_discards_real_client_info_before_handler_runs() {
    let observed = send_initialize_and_observe(false).await;
    assert_eq!(observed.from_request_arg.name, "totally-real-client"); // handler got the real value
    let peer_client_info = observed.from_peer_snapshot.unwrap();
    assert_eq!(peer_client_info.name, "rmcp"); // but Peer::peer_info() — what gets logged — did not
}

#[tokio::test]
async fn stateful_mode_preserves_real_client_info() {
    let observed = send_initialize_and_observe(true).await;
    let peer_client_info = observed.from_peer_snapshot.unwrap();
    assert_eq!(peer_client_info.name, "totally-real-client"); // control: works fine when stateful
}

Both tests pass as written against rmcp = "3.1.2", demonstrating:

  • The initialize handler's own typed argument (request.client_info) correctly contains the real client identity — the request body is parsed correctly.
  • context.peer.peer_info() — the exact value serve_inner logs — is the placeholder in stateless mode (legacy_session_mode = false), but correctly carries the real client identity in stateful mode (legacy_session_mode = true, the default). Flipping only that one config flag flips the outcome, isolating the cause to the stateless code path.

Impact

Every server operator using StreamableHttpService in stateless mode sees client_info: "rmcp"/"<sdk version>" in their server-side logs for every real client, regardless of which client actually connected (Claude Code, Gemini CLI, custom clients, etc.), making per-client observability impossible without additional wire-level instrumentation (e.g. logging raw User-Agent headers separately, as we ended up doing as a workaround).

Suggested fix

For the initialize request specifically, thread the real init.params.client_info / init.params.capabilities (not just protocol_version) into the synthesized InitializeRequestParams in peer_info_for_stateless_request, so the placeholder is only used for non-initialize requests where there genuinely is no session to recover identity from. Alternatively/additionally, consider not logging the synthesized placeholder via the same "Service initialized as server" trace used for genuine handshakes, or tagging it distinctly (e.g. a debug-level span field marking it as synthetic) so downstream log consumers aren't misled into thinking it reflects a real negotiated peer.

Environment

  • rmcp 3.1.2
  • Reproduced with axum 0.8, tower 0.5, tokio 1.x

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with peer_info_for_stateless_request in crates/rmcp/src/transport/streamable_http_server/tower.rs and the peer initialization and logging path in service.rs, especially serve_directly_with_ct and serve_inner. Run the self-contained stateless and stateful tests from the issue; done means stateless initialization preserves the real client identity in peer_info while the stateful control case remains passing.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
api
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.