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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions