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
Summary
In stateless streamable-HTTP mode (
StreamableHttpServerConfig::legacy_session_mode = false), every request — including the realinitializehandshake — gets aPeer::peer_info()whoseclient_infois alwaysImplementation::default()(i.e.{"name": "rmcp", "version": "<crate version>"}), never the client's realclientInfo. This value is then logged byserve_inner'stracing::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_requestincrates/rmcp/src/transport/streamable_http_server/tower.rs:Only
protocol_versionis reconstructed from real data (the request body forinitialize, or theMCP-Protocol-Versionheader otherwise).client_infoandcapabilitiesare always the blank/default placeholder — even for the literalinitializerequest, whose body does contain the realclientInfo, but that value is never threaded into the synthesizedpeer_info.This placeholder feeds into
serve_directly_with_ct(service.rs#L1281-1297):serve_inner(service.rs#L1338-1343) then logs it immediately and synchronously, before the request is actually dispatched to the handler:So even a handler that overrides
initializeand callscontext.peer.set_peer_info(request.clone())with the realclientInfo(the defaultServerHandler::initializeimpl does exactly this, handler/server.rs#L318-330) updates thePeertoo late — the log line has already fired with the placeholder, becauseserve_inneris a plain (non-async) function that samplespeer.peer_info()synchronously right afterPeer::new, before any request processing task runs.Reproduction (self-contained test, no external client needed)
Both tests pass as written against
rmcp = "3.1.2", demonstrating:initializehandler'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 valueserve_innerlogs — 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
StreamableHttpServicein stateless mode seesclient_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 rawUser-Agentheaders separately, as we ended up doing as a workaround).Suggested fix
For the
initializerequest specifically, thread the realinit.params.client_info/init.params.capabilities(not justprotocol_version) into the synthesizedInitializeRequestParamsinpeer_info_for_stateless_request, so the placeholder is only used for non-initializerequests 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
rmcp3.1.2axum0.8,tower0.5,tokio1.x