|
| 1 | +//! SEP-2260 follow-up (#1033): stream-based receive-side enforcement. |
| 2 | +//! |
| 3 | +//! Scripted streamable HTTP "server": answers a legacy initialize with |
| 4 | +//! protocol 2026-07-28 and a session id (spec-legal; rmcp's own server is |
| 5 | +//! stateless at that version, but the client must be correct against any |
| 6 | +//! server), so the client has BOTH a standalone GET stream and strict |
| 7 | +//! SEP-2260 enforcement. |
| 8 | +#![cfg(all( |
| 9 | + feature = "client", |
| 10 | + feature = "transport-streamable-http-client", |
| 11 | + not(feature = "local") |
| 12 | +))] |
| 13 | +#![allow(deprecated)] |
| 14 | + |
| 15 | +use std::{collections::HashMap, sync::Arc, time::Duration}; |
| 16 | + |
| 17 | +use futures::{StreamExt, stream::BoxStream}; |
| 18 | +use http::{HeaderName, HeaderValue}; |
| 19 | +use rmcp::{ |
| 20 | + ClientHandler, |
| 21 | + model::{ |
| 22 | + ClientInfo, ClientJsonRpcMessage, CreateMessageRequestParams, CreateMessageResult, |
| 23 | + ProtocolVersion, SamplingMessage, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, |
| 24 | + }, |
| 25 | + service::{ClientLifecycleMode, RequestContext, RoleClient, serve_client_with_lifecycle}, |
| 26 | + transport::streamable_http_client::{ |
| 27 | + StreamableHttpClient, StreamableHttpClientTransport, StreamableHttpClientTransportConfig, |
| 28 | + StreamableHttpError, StreamableHttpPostResponse, |
| 29 | + }, |
| 30 | +}; |
| 31 | +use serde_json::{Value, json}; |
| 32 | +use sse_stream::{Error as SseError, Sse}; |
| 33 | +use tokio::sync::{Mutex, mpsc}; |
| 34 | + |
| 35 | +fn to_sse(message: Value) -> Result<Sse, SseError> { |
| 36 | + Ok(Sse { |
| 37 | + event: None, |
| 38 | + data: Some(message.to_string()), |
| 39 | + id: None, |
| 40 | + retry: None, |
| 41 | + }) |
| 42 | +} |
| 43 | + |
| 44 | +fn message_stream(rx: mpsc::Receiver<Value>) -> BoxStream<'static, Result<Sse, SseError>> { |
| 45 | + tokio_stream::wrappers::ReceiverStream::new(rx) |
| 46 | + .map(to_sse) |
| 47 | + .boxed() |
| 48 | +} |
| 49 | + |
| 50 | +/// Scripted server: initialize -> JSON init result (2026-07-28 + session); |
| 51 | +/// first non-initialize request POST -> SSE stream fed by `post_stream`; |
| 52 | +/// everything else -> Accepted. Every message the client POSTs is forwarded |
| 53 | +/// to `posted`. |
| 54 | +#[derive(Clone)] |
| 55 | +struct ScriptedServer { |
| 56 | + get_stream: Arc<Mutex<Option<mpsc::Receiver<Value>>>>, |
| 57 | + post_stream: Arc<Mutex<Option<mpsc::Receiver<Value>>>>, |
| 58 | + posted: mpsc::UnboundedSender<Value>, |
| 59 | +} |
| 60 | + |
| 61 | +impl StreamableHttpClient for ScriptedServer { |
| 62 | + type Error = std::io::Error; |
| 63 | + |
| 64 | + async fn post_message( |
| 65 | + &self, |
| 66 | + _uri: Arc<str>, |
| 67 | + message: ClientJsonRpcMessage, |
| 68 | + _session_id: Option<Arc<str>>, |
| 69 | + _auth_header: Option<String>, |
| 70 | + _custom_headers: HashMap<HeaderName, HeaderValue>, |
| 71 | + ) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> { |
| 72 | + let value = serde_json::to_value(&message).expect("serialize client message"); |
| 73 | + self.posted.send(value.clone()).expect("test alive"); |
| 74 | + if value["method"] == "initialize" { |
| 75 | + let mut info = ServerInfo::new(ServerCapabilities::default()); |
| 76 | + info.protocol_version = ProtocolVersion::V_2026_07_28; |
| 77 | + let response = ServerJsonRpcMessage::response( |
| 78 | + rmcp::model::ServerResult::InitializeResult(info), |
| 79 | + serde_json::from_value(value["id"].clone()).expect("request id"), |
| 80 | + ); |
| 81 | + return Ok(StreamableHttpPostResponse::Json( |
| 82 | + response, |
| 83 | + Some("scripted-session".into()), |
| 84 | + )); |
| 85 | + } |
| 86 | + if matches!(message, ClientJsonRpcMessage::Request(_)) { |
| 87 | + let rx = self |
| 88 | + .post_stream |
| 89 | + .lock() |
| 90 | + .await |
| 91 | + .take() |
| 92 | + .expect("exactly one non-initialize request POST in this test"); |
| 93 | + return Ok(StreamableHttpPostResponse::Sse(message_stream(rx), None)); |
| 94 | + } |
| 95 | + Ok(StreamableHttpPostResponse::Accepted) |
| 96 | + } |
| 97 | + |
| 98 | + async fn delete_session( |
| 99 | + &self, |
| 100 | + _uri: Arc<str>, |
| 101 | + _session_id: Arc<str>, |
| 102 | + _auth_header: Option<String>, |
| 103 | + _custom_headers: HashMap<HeaderName, HeaderValue>, |
| 104 | + ) -> Result<(), StreamableHttpError<Self::Error>> { |
| 105 | + Ok(()) |
| 106 | + } |
| 107 | + |
| 108 | + async fn get_stream( |
| 109 | + &self, |
| 110 | + _uri: Arc<str>, |
| 111 | + _session_id: Option<Arc<str>>, |
| 112 | + _last_event_id: Option<String>, |
| 113 | + _auth_header: Option<String>, |
| 114 | + _custom_headers: HashMap<HeaderName, HeaderValue>, |
| 115 | + ) -> Result<BoxStream<'static, Result<Sse, SseError>>, StreamableHttpError<Self::Error>> { |
| 116 | + match self.get_stream.lock().await.take() { |
| 117 | + Some(rx) => Ok(message_stream(rx)), |
| 118 | + // Reconnect after the scripted stream ends: stay silent. |
| 119 | + None => Ok(futures::stream::pending().boxed()), |
| 120 | + } |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +#[derive(Clone)] |
| 125 | +struct SamplingClient { |
| 126 | + // Some(tx): forward sampling params; None: sampling must never reach the handler. |
| 127 | + on_sampling: Option<mpsc::UnboundedSender<CreateMessageRequestParams>>, |
| 128 | +} |
| 129 | + |
| 130 | +impl ClientHandler for SamplingClient { |
| 131 | + async fn create_message( |
| 132 | + &self, |
| 133 | + params: CreateMessageRequestParams, |
| 134 | + _context: RequestContext<RoleClient>, |
| 135 | + ) -> Result<CreateMessageResult, rmcp::ErrorData> { |
| 136 | + let Some(tx) = &self.on_sampling else { |
| 137 | + panic!("sampling request must not reach the handler in this test"); |
| 138 | + }; |
| 139 | + tx.send(params).expect("test alive"); |
| 140 | + Ok(CreateMessageResult::new( |
| 141 | + SamplingMessage::assistant_text("pong"), |
| 142 | + "test-model".to_string(), |
| 143 | + )) |
| 144 | + } |
| 145 | + |
| 146 | + fn get_info(&self) -> ClientInfo { |
| 147 | + ClientInfo::default() |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +fn sampling_request(id: u32) -> Value { |
| 152 | + json!({ |
| 153 | + "jsonrpc": "2.0", |
| 154 | + "id": id, |
| 155 | + "method": "sampling/createMessage", |
| 156 | + "params": { |
| 157 | + "messages": [{ "role": "user", "content": { "type": "text", "text": "hi" } }], |
| 158 | + "maxTokens": 16 |
| 159 | + } |
| 160 | + }) |
| 161 | +} |
| 162 | + |
| 163 | +fn tools_list_response(id: &Value) -> Value { |
| 164 | + json!({ "jsonrpc": "2.0", "id": id, "result": { "tools": [] } }) |
| 165 | +} |
| 166 | + |
| 167 | +async fn next_posted(posted: &mut mpsc::UnboundedReceiver<Value>) -> Value { |
| 168 | + tokio::time::timeout(Duration::from_secs(5), posted.recv()) |
| 169 | + .await |
| 170 | + .expect("posted message within 5s") |
| 171 | + .expect("channel open") |
| 172 | +} |
| 173 | + |
| 174 | +/// Drive startup + one in-flight tools/list; return (client, posted rx, |
| 175 | +/// get stream tx, post stream tx, in-flight call handle, tools/list id). |
| 176 | +async fn setup( |
| 177 | + handler: SamplingClient, |
| 178 | +) -> ( |
| 179 | + rmcp::service::RunningService<RoleClient, SamplingClient>, |
| 180 | + mpsc::UnboundedReceiver<Value>, |
| 181 | + mpsc::Sender<Value>, |
| 182 | + mpsc::Sender<Value>, |
| 183 | + tokio::task::JoinHandle<Result<rmcp::model::ListToolsResult, rmcp::ServiceError>>, |
| 184 | + Value, |
| 185 | +) { |
| 186 | + let (get_tx, get_rx) = mpsc::channel(8); |
| 187 | + let (post_tx, post_rx) = mpsc::channel(8); |
| 188 | + let (posted_tx, mut posted_rx) = mpsc::unbounded_channel(); |
| 189 | + let server = ScriptedServer { |
| 190 | + get_stream: Arc::new(Mutex::new(Some(get_rx))), |
| 191 | + post_stream: Arc::new(Mutex::new(Some(post_rx))), |
| 192 | + posted: posted_tx, |
| 193 | + }; |
| 194 | + let transport = StreamableHttpClientTransport::with_client( |
| 195 | + server, |
| 196 | + StreamableHttpClientTransportConfig::with_uri("http://scripted/mcp"), |
| 197 | + ); |
| 198 | + let client = serve_client_with_lifecycle(handler, transport, ClientLifecycleMode::Initialize) |
| 199 | + .await |
| 200 | + .expect("initialize against scripted server"); |
| 201 | + |
| 202 | + // initialize + notifications/initialized already posted during startup. |
| 203 | + assert_eq!(next_posted(&mut posted_rx).await["method"], "initialize"); |
| 204 | + assert_eq!( |
| 205 | + next_posted(&mut posted_rx).await["method"], |
| 206 | + "notifications/initialized" |
| 207 | + ); |
| 208 | + |
| 209 | + // Unrelated outbound request, kept in flight (response withheld). |
| 210 | + let peer = client.peer().clone(); |
| 211 | + let call = tokio::spawn(async move { peer.list_tools(None).await }); |
| 212 | + let tools_list = next_posted(&mut posted_rx).await; |
| 213 | + assert_eq!(tools_list["method"], "tools/list"); |
| 214 | + let tools_list_id = tools_list["id"].clone(); |
| 215 | + |
| 216 | + (client, posted_rx, get_tx, post_tx, call, tools_list_id) |
| 217 | +} |
| 218 | + |
| 219 | +/// #1033 scenario 1: a restricted request on the standalone GET stream while |
| 220 | +/// an unrelated outbound request is in flight must be rejected with -32602. |
| 221 | +/// (The coarse check from #1029 incorrectly accepted this.) |
| 222 | +#[tokio::test] |
| 223 | +async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight() |
| 224 | +-> anyhow::Result<()> { |
| 225 | + let (client, mut posted_rx, get_tx, post_tx, call, tools_list_id) = |
| 226 | + setup(SamplingClient { on_sampling: None }).await; |
| 227 | + |
| 228 | + get_tx.send(sampling_request(100)).await?; |
| 229 | + |
| 230 | + let rejection = next_posted(&mut posted_rx).await; |
| 231 | + assert_eq!( |
| 232 | + rejection["id"], 100, |
| 233 | + "reply to the sampling request: {rejection}" |
| 234 | + ); |
| 235 | + assert_eq!( |
| 236 | + rejection["error"]["code"], -32602, |
| 237 | + "SEP-2260: GET-stream request must be rejected even with an unrelated \ |
| 238 | + request in flight, got {rejection}" |
| 239 | + ); |
| 240 | + |
| 241 | + post_tx.send(tools_list_response(&tools_list_id)).await?; |
| 242 | + call.await??; |
| 243 | + client.cancel().await?; |
| 244 | + Ok(()) |
| 245 | +} |
| 246 | + |
| 247 | +/// Positive twin: the same restricted request arriving on the SSE stream of |
| 248 | +/// the originating POST is dispatched to the handler and answered. |
| 249 | +#[tokio::test] |
| 250 | +async fn restricted_request_on_originating_post_stream_is_dispatched() -> anyhow::Result<()> { |
| 251 | + let (sampled_tx, mut sampled_rx) = mpsc::unbounded_channel(); |
| 252 | + let (client, mut posted_rx, _get_tx, post_tx, call, tools_list_id) = setup(SamplingClient { |
| 253 | + on_sampling: Some(sampled_tx), |
| 254 | + }) |
| 255 | + .await; |
| 256 | + |
| 257 | + post_tx.send(sampling_request(200)).await?; |
| 258 | + |
| 259 | + let response = next_posted(&mut posted_rx).await; |
| 260 | + assert_eq!( |
| 261 | + response["id"], 200, |
| 262 | + "reply to the sampling request: {response}" |
| 263 | + ); |
| 264 | + assert_eq!( |
| 265 | + response["result"]["model"], "test-model", |
| 266 | + "request on the originating POST stream must reach the handler, got {response}" |
| 267 | + ); |
| 268 | + tokio::time::timeout(Duration::from_secs(5), sampled_rx.recv()) |
| 269 | + .await? |
| 270 | + .expect("handler invoked"); |
| 271 | + |
| 272 | + post_tx.send(tools_list_response(&tools_list_id)).await?; |
| 273 | + call.await??; |
| 274 | + client.cancel().await?; |
| 275 | + Ok(()) |
| 276 | +} |
0 commit comments