rust-sdk/crates/rmcp/tests/test_sep_2260_stream_routing.rs
camillelawrence 397d416fdc
feat: route SEP-2260 associated server requests to the originating SSE stream (#1029)
* feat: implement SEP-2260 require server requests to associate with client requests

* feat(service): attach originating request id to in-handler outbound requests (SEP-2260)

Reuses the ORIGINATING_REQUEST task-local from #1027; the marker rides
the request's non-serialized Extensions so the streamable HTTP server
can route associated requests to the originating SSE stream.

* feat(transport): route associated server requests to originating SSE stream (SEP-2260)

* test: end-to-end SEP-2260 stream routing over streamable HTTP

* test: register SEP-2260 routing test with required-features; drop dead list_tools

* docs: document SEP-2260 association requirement and stream routing

* docs: fix redundant explicit link on OriginatingRequestId

* docs: note marker is role-agnostic; add reason to deprecated expect

Review follow-ups: the OriginatingRequestId rustdoc now states the marker
is attached for both roles with only the server transport reading it, and
the test's deprecation suppression carries a reason per house style.

* docs: condense inline comments to repo convention

Trims multi-line inline comments added in this branch down to the terse
one-to-two-line style used elsewhere in the codebase, keeping only the
non-obvious rationale (spec references, fallback invariant, version choice).

* docs: condense SEP-2260 rustdoc sections

Tightens the duplicated per-method association section from eleven lines
to five, and trims the marker and SessionManager docs to the same
information in fewer words.

* docs: single-source SEP-2260 caller requirements on OriginatingRequestId

* feat(service): reject unassociated server-to-client requests on the client (SEP-2260)

Implements the client receive-side of SEP-2260: restricted server
requests (sampling/createMessage, roots/list, elicitation/create)
received while the client has no outbound request in flight are
answered with -32602 invalid params instead of being dispatched to the
handler. Gated on negotiated protocol >= 2026-07-28; ping is exempt.

With a request in flight we cannot tell which one the server request
belongs to (SEP-2260 defines no wire field), so this is a deliberate
under-approximation of the spec's SHOULD; exact stream-based
enforcement for streamable HTTP needs receive-side provenance plumbing
and is left as a follow-up.

---------

Co-authored-by: Alex Hancock <alexhancock@block.xyz>
2026-07-23 13:12:20 -04:00

165 lines
5.5 KiB
Rust

//! SEP-2260 end-to-end: in-handler server→client requests ride the originating
//! POST's SSE stream, never the standalone GET stream.
#![cfg(not(feature = "local"))]
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use futures::StreamExt;
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ElicitRequestParams,
ElicitationSchema, ServerCapabilities, ServerInfo,
},
service::RequestContext,
transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
};
use serde_json::json;
use tokio_util::sync::CancellationToken;
#[derive(Clone)]
struct ElicitingServer;
impl ServerHandler for ElicitingServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
async fn call_tool(
&self,
_request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
// Never answered: the test only checks the request is emitted on the right stream.
let _ = context
.peer
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "need input".to_string(),
requested_schema: ElicitationSchema::new(BTreeMap::new()),
})
.await;
Ok(CallToolResult::success(vec![ContentBlock::text("done")]).into())
}
}
async fn start_server(ct: CancellationToken) -> String {
let service = StreamableHttpService::new(
move || Ok(ElicitingServer),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!(
"http://127.0.0.1:{}/mcp",
listener.local_addr().unwrap().port()
);
let ct = ct.clone();
tokio::spawn(async move {
axum::serve(listener, router)
.with_graceful_shutdown(async move { ct.cancelled().await })
.await
.unwrap();
});
tokio::time::sleep(Duration::from_millis(100)).await;
url
}
/// Read an SSE byte stream until `needle` appears or timeout.
async fn sse_contains(resp: reqwest::Response, needle: &str, timeout: Duration) -> bool {
let mut stream = resp.bytes_stream();
tokio::time::timeout(timeout, async {
let mut buffer = String::new();
while let Some(Ok(chunk)) = stream.next().await {
buffer.push_str(&String::from_utf8_lossy(&chunk));
if buffer.contains(needle) {
return true;
}
}
false
})
.await
.unwrap_or(false)
}
#[tokio::test]
async fn elicitation_rides_originating_post_stream_not_standalone_get() {
let ct = CancellationToken::new();
let url = start_server(ct.clone()).await;
let client = reqwest::Client::new();
// 2025-11-25 is the latest session-carrying version; SEP-2567 serves
// 2026-07-28+ statelessly, with no standalone GET stream to test against.
let resp = client
.post(&url)
.header("Accept", "text/event-stream, application/json")
.header("Content-Type", "application/json")
.json(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": { "elicitation": {} },
"clientInfo": { "name": "test-client", "version": "1.0.0" }
}
}))
.send()
.await
.unwrap();
assert!(resp.status().is_success());
let session_id = resp
.headers()
.get("Mcp-Session-Id")
.expect("session id")
.to_str()
.unwrap()
.to_string();
client
.post(&url)
.header("Accept", "text/event-stream, application/json")
.header("Content-Type", "application/json")
.header("Mcp-Session-Id", &session_id)
.json(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}))
.send()
.await
.unwrap();
// Standalone GET stream — must NEVER carry the elicitation request.
let get_stream = client
.get(&url)
.header("Accept", "text/event-stream")
.header("Mcp-Session-Id", &session_id)
.send()
.await
.unwrap();
assert_eq!(get_stream.status(), 200);
// The in-handler elicitation must appear on this tools/call SSE stream.
let post_stream = client
.post(&url)
.header("Accept", "text/event-stream, application/json")
.header("Content-Type", "application/json")
.header("Mcp-Session-Id", &session_id)
.json(&json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "ask", "arguments": {} }
}))
.send()
.await
.unwrap();
assert!(post_stream.status().is_success());
assert!(
sse_contains(post_stream, "elicitation/create", Duration::from_secs(5)).await,
"elicitation request must be delivered on the originating POST SSE stream"
);
assert!(
!sse_contains(get_stream, "elicitation/create", Duration::from_millis(500)).await,
"standalone GET stream must not carry the elicitation request"
);
ct.cancel();
}