Skip to content

Commit 45865c1

Browse files
committed
test: end-to-end SEP-2260 stream-based enforcement over streamable HTTP (#1033)
1 parent 86b23b2 commit 45865c1

1 file changed

Lines changed: 276 additions & 0 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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

Comments
 (0)