Skip to content

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium: important but non-blocking improvement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions