rust-sdk/crates/rmcp/tests/test_streamable_http_event_store.rs
Teddy Tennant 3075dc9152
fix(model): deprecate ServerInfo and ClientInfo aliases (#1156)
* fix(model): deprecate ServerInfo and ClientInfo aliases

Those names collide with the protocol's serverInfo/clientInfo fields,
which are only Implementation identity. Prefer InitializeResult and
InitializeRequestParams. Handler trait signatures, the tool_handler
macro output, tests, examples and docs now use the canonical types; the
aliases remain as deprecated shims for the rest of 3.x.

Fixes #1082

* fix(model): keep public signatures on the deprecated aliases

The public API check compares rendered signatures, and rustdoc records a
type alias by the name it was written with. Respelling
`ServerHandler::get_info`, `ClientHandler::get_info`,
`DiscoverResult::from_server_info` and `impl ClientHandler for ClientInfo`
onto `InitializeResult`/`InitializeRequestParams` therefore reads as
changed public items even though the aliases resolve to those exact types
and nothing downstream breaks.

Put those four signatures back on the aliases so the published API is
byte-identical, and note why at the alias definitions. The deprecation
still steers new code, and the macro output, tests, examples and docs
keep using the canonical names.

* fix(model): migrate leftover ClientInfo test sites

Two tests landed on main after the alias deprecation and still named
ClientInfo. Clippy -D warnings and the no-local feature test job both
failed on them.
2026-09-12 12:11:30 -04:00

372 lines
13 KiB
Rust

#![cfg(all(
feature = "client",
feature = "server",
feature = "transport-streamable-http-client-reqwest",
feature = "transport-streamable-http-server",
not(feature = "local")
))]
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use futures::StreamExt;
use rmcp::{
ErrorData, ServerHandler,
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, InitializeResult,
ProgressNotificationParam, ServerCapabilities,
},
service::RequestContext,
transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService,
session::{
EventStore, EventStoreError, EventStream, ServerSseMessage, SessionState, SessionStore,
SessionStoreError, local::LocalSessionManager, never::NeverSessionManager,
},
},
};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Default)]
struct InMemorySessionStore(Arc<RwLock<HashMap<String, SessionState>>>);
#[async_trait::async_trait]
impl SessionStore for InMemorySessionStore {
async fn load(&self, session_id: &str) -> Result<Option<SessionState>, SessionStoreError> {
Ok(self.0.read().await.get(session_id).cloned())
}
async fn store(&self, session_id: &str, state: &SessionState) -> Result<(), SessionStoreError> {
self.0
.write()
.await
.insert(session_id.to_owned(), state.clone());
Ok(())
}
async fn delete(&self, session_id: &str) -> Result<(), SessionStoreError> {
self.0.write().await.remove(session_id);
Ok(())
}
}
#[derive(Clone)]
struct StoredEvent {
stream_id: String,
event: ServerSseMessage,
}
#[derive(Clone, Default)]
struct InMemoryEventStore {
events: Arc<RwLock<Vec<StoredEvent>>>,
next_id: Arc<AtomicU64>,
}
#[async_trait::async_trait]
impl EventStore for InMemoryEventStore {
async fn store_event(
&self,
stream_id: &str,
event: &ServerSseMessage,
) -> Result<String, EventStoreError> {
let event_id = format!("event-{}", self.next_id.fetch_add(1, Ordering::Relaxed));
let mut event = event.clone();
event.event_id = Some(event_id.clone());
self.events.write().await.push(StoredEvent {
stream_id: stream_id.to_owned(),
event,
});
Ok(event_id)
}
async fn replay_events_after(
&self,
last_event_id: &str,
) -> Result<EventStream, EventStoreError> {
let events = self.events.read().await;
let last_index = events
.iter()
.position(|stored| stored.event.event_id.as_deref() == Some(last_event_id))
.ok_or_else(|| std::io::Error::other("event not found"))?;
let stream_id = events[last_index].stream_id.clone();
let replay = events
.iter()
.skip(last_index + 1)
.filter(|stored| stored.stream_id == stream_id)
.map(|stored| stored.event.clone())
.collect::<Vec<_>>();
Ok(Box::pin(futures::stream::iter(replay)))
}
}
#[derive(Clone)]
struct ProgressServer;
impl ServerHandler for ProgressServer {
fn get_info(&self) -> InitializeResult {
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<rmcp::RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
if request.name == "progress" || request.name == "slow-progress" {
let progress_token = context
.meta
.get_progress_token()
.expect("request includes progressToken");
context
.peer
.notify_progress(
ProgressNotificationParam::new(progress_token, 50.0)
.with_total(100.0)
.with_message("working"),
)
.await
.expect("progress notification is delivered");
}
if request.name == "slow-progress" {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
}
}
async fn spawn_server(
session_store: Arc<dyn SessionStore>,
event_store: Arc<dyn EventStore>,
cancellation_token: &CancellationToken,
legacy_session_mode: bool,
) -> anyhow::Result<(String, tokio::task::JoinHandle<()>)> {
let config = {
let mut config = StreamableHttpServerConfig::default();
config.sse_keep_alive = None;
config.legacy_session_mode = legacy_session_mode;
config.cancellation_token = cancellation_token.child_token();
config.session_store = Some(session_store);
config
};
let router = if legacy_session_mode {
let session_manager =
Arc::new(LocalSessionManager::default().with_event_store(event_store));
let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config);
axum::Router::new().nest_service("/mcp", service)
} else {
let session_manager =
Arc::new(NeverSessionManager::default().with_event_store(event_store));
let service = StreamableHttpService::new(|| Ok(ProgressServer), session_manager, config);
axum::Router::new().nest_service("/mcp", service)
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;
let handle = tokio::spawn({
let cancellation_token = cancellation_token.clone();
async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move { cancellation_token.cancelled_owned().await })
.await;
}
});
Ok((format!("http://{address}/mcp"), handle))
}
fn event_id_containing<'a>(body: &'a str, needle: &str) -> Option<&'a str> {
body.split("\n\n")
.find(|event| event.contains(needle))?
.lines()
.find_map(|line| line.strip_prefix("id: "))
}
#[tokio::test]
async fn restored_instance_replays_events_from_shared_store() -> anyhow::Result<()> {
let session_store: Arc<dyn SessionStore> = Arc::new(InMemorySessionStore::default());
let event_store: Arc<dyn EventStore> = Arc::new(InMemoryEventStore::default());
let http = reqwest::Client::new();
let cancellation_a = CancellationToken::new();
let (url_a, server_a) = spawn_server(
session_store.clone(),
event_store.clone(),
&cancellation_a,
true,
)
.await?;
let initialize = http
.post(&url_a)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#)
.send()
.await?;
let session_id = initialize
.headers()
.get("mcp-session-id")
.expect("initialize returns a session ID")
.to_str()?
.to_owned();
let _ = initialize.text().await?;
let initialized_status = http
.post(&url_a)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Mcp-Session-Id", &session_id)
.header("Mcp-Protocol-Version", "2025-06-18")
.body(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#)
.send()
.await?
.status();
assert_eq!(initialized_status, reqwest::StatusCode::ACCEPTED);
let original_body = http
.post(&url_a)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Mcp-Session-Id", &session_id)
.header("Mcp-Protocol-Version", "2025-06-18")
.body(r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"progress","arguments":{},"_meta":{"progressToken":"progress-1"}}}"#)
.send()
.await?
.text()
.await?;
let progress_event_id = event_id_containing(&original_body, "notifications/progress")
.expect("progress event has a persisted event ID")
.to_owned();
cancellation_a.cancel();
server_a.await?;
let cancellation_b = CancellationToken::new();
let (url_b, server_b) = spawn_server(session_store, event_store, &cancellation_b, true).await?;
let replay = http
.get(&url_b)
.header("Accept", "text/event-stream")
.header("Mcp-Session-Id", &session_id)
.header("Mcp-Protocol-Version", "2025-06-18")
.header("Last-Event-ID", progress_event_id)
.send()
.await?;
assert_eq!(replay.status(), reqwest::StatusCode::OK);
let replay_body = replay.text().await?;
assert!(
replay_body.contains(r#""id":2"#),
"instance B should replay the final response stored by instance A: {replay_body}"
);
cancellation_b.cancel();
server_b.await?;
Ok(())
}
#[tokio::test]
async fn stateless_instance_replays_events_from_shared_store() -> anyhow::Result<()> {
let session_store: Arc<dyn SessionStore> = Arc::new(InMemorySessionStore::default());
let event_store = Arc::new(InMemoryEventStore::default());
let http = reqwest::Client::new();
let cancellation_a = CancellationToken::new();
let (url_a, server_a) = spawn_server(
session_store.clone(),
event_store.clone(),
&cancellation_a,
false,
)
.await?;
let original = http
.post(&url_a)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", "tools/call")
.header("Mcp-Name", "slow-progress")
.body(
r#"{
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params":{
"name":"slow-progress",
"arguments":{},
"_meta":{
"progressToken":"progress-1",
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},
"io.modelcontextprotocol/clientCapabilities":{}
}
}
}"#,
)
.send()
.await?;
assert_eq!(original.status(), reqwest::StatusCode::OK);
assert!(
original.headers().get("mcp-session-id").is_none(),
"stateless response must not create a session"
);
let mut body = original.bytes_stream();
let mut received = String::new();
let progress_event_id = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let chunk = body
.next()
.await
.expect("response remains open until progress arrives")?;
received.push_str(&String::from_utf8_lossy(&chunk));
if let Some(event_id) = event_id_containing(&received, "notifications/progress") {
return Ok::<_, reqwest::Error>(event_id.to_owned());
}
}
})
.await??;
drop(body);
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let stored_response = event_store.events.read().await.iter().any(|stored| {
stored.event.message.as_ref().is_some_and(|message| {
matches!(
message.as_ref(),
rmcp::model::ServerJsonRpcMessage::Response(_)
)
})
});
if stored_response {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
})
.await?;
cancellation_a.cancel();
server_a.await?;
let cancellation_b = CancellationToken::new();
let (url_b, server_b) =
spawn_server(session_store, event_store, &cancellation_b, false).await?;
let replay = http
.get(&url_b)
.header("Accept", "text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("Last-Event-ID", progress_event_id)
.send()
.await?;
assert_eq!(replay.status(), reqwest::StatusCode::OK);
let replay_body = replay.text().await?;
assert!(
replay_body.contains(r#""id":2"#),
"instance B should replay the stateless response stored by instance A: {replay_body}"
);
cancellation_b.cancel();
server_b.await?;
Ok(())
}