Repository navigation
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
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 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
rmcp3.1.2- Reproduced with
axum0.8,tower0.5,tokio1.x
Activity
github-actions commentedon Oct 17, 2026
This issue has had no activity for 60 days and is now marked as stale. It will be closed in 14 days if there is no further activity. Add the
keeplabel to keep it open.