Skip to content

Commit c3bab3a

Browse files
committed
test: harden SEP-2260 stream-enforcement e2e (#1033)
Detect handler invocation via a channel asserted empty instead of a panic in a spawned task (swallowed, cannot fail the test); surface scripted-server misuse as transport errors rather than panics in the transport task; bound the tail awaits with 5s timeouts. Correct the header comment: a 2026-07-28 server minting a session id is not spec-legal (SEP-2567 removes sessions and the GET endpoint) — the scripted server is deliberately non-conforming, which is the point of receive-side enforcement.
1 parent adcb379 commit c3bab3a

1 file changed

Lines changed: 84 additions & 51 deletions

File tree

crates/rmcp/tests/test_sep_2260_stream_enforcement.rs

Lines changed: 84 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
//! SEP-2260 follow-up (#1033): stream-based receive-side enforcement.
22
//!
33
//! 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
4+
//! protocol 2026-07-28 AND a session id. That combination is NOT
5+
//! spec-compliant: the 2026-07-28 revision removes protocol-level sessions
6+
//! and the standalone GET stream (SEP-2567; transports spec: "do not mint
7+
//! or echo session IDs"). Receive-side enforcement (#1033) exists precisely
8+
//! to protect the client from non-conforming servers, and rmcp's client
9+
//! tolerates the session id and opens the standalone GET stream — so this
10+
//! is the reachable path where the client has BOTH a GET stream and strict
711
//! SEP-2260 enforcement.
812
#![cfg(all(
913
feature = "client",
1014
feature = "transport-streamable-http-client",
1115
not(feature = "local")
1216
))]
13-
#![allow(deprecated)]
17+
#![expect(
18+
deprecated,
19+
reason = "Sampling is deprecated by SEP-2577 but remains the canonical restricted request"
20+
)]
1421

1522
use std::{collections::HashMap, sync::Arc, time::Duration};
1623

@@ -70,7 +77,8 @@ impl StreamableHttpClient for ScriptedServer {
7077
_custom_headers: HashMap<HeaderName, HeaderValue>,
7178
) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
7279
let value = serde_json::to_value(&message).expect("serialize client message");
73-
self.posted.send(value.clone()).expect("test alive");
80+
// Receiver drop is normal at test teardown; never panic in the transport task.
81+
let _ = self.posted.send(value.clone());
7482
if value["method"] == "initialize" {
7583
let mut info = ServerInfo::new(ServerCapabilities::default());
7684
info.protocol_version = ProtocolVersion::V_2026_07_28;
@@ -84,12 +92,14 @@ impl StreamableHttpClient for ScriptedServer {
8492
));
8593
}
8694
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");
95+
// Fail as a transport error rather than panicking: this code runs
96+
// in the transport task, where a panic is swallowed and shows up
97+
// only as an opaque timeout in the test.
98+
let rx = self.post_stream.lock().await.take().ok_or_else(|| {
99+
StreamableHttpError::Client(std::io::Error::other(
100+
"scripted server expects exactly one non-initialize request POST",
101+
))
102+
})?;
93103
return Ok(StreamableHttpPostResponse::Sse(message_stream(rx), None));
94104
}
95105
Ok(StreamableHttpPostResponse::Accepted)
@@ -123,8 +133,10 @@ impl StreamableHttpClient for ScriptedServer {
123133

124134
#[derive(Clone)]
125135
struct SamplingClient {
126-
// Some(tx): forward sampling params; None: sampling must never reach the handler.
127-
on_sampling: Option<mpsc::UnboundedSender<CreateMessageRequestParams>>,
136+
// Every invocation is recorded here. Tests assert on the receiver (the
137+
// negative test asserts it stays empty); a panic in this handler would
138+
// run in a spawned task and be silently swallowed.
139+
sampled: mpsc::UnboundedSender<CreateMessageRequestParams>,
128140
}
129141

130142
impl ClientHandler for SamplingClient {
@@ -133,10 +145,7 @@ impl ClientHandler for SamplingClient {
133145
params: CreateMessageRequestParams,
134146
_context: RequestContext<RoleClient>,
135147
) -> 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");
148+
let _ = self.sampled.send(params);
140149
Ok(CreateMessageResult::new(
141150
SamplingMessage::assistant_text("pong"),
142151
"test-model".to_string(),
@@ -171,21 +180,27 @@ async fn next_posted(posted: &mut mpsc::UnboundedReceiver<Value>) -> Value {
171180
.expect("channel open")
172181
}
173182

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-
) {
183+
struct Harness {
184+
client: rmcp::service::RunningService<RoleClient, SamplingClient>,
185+
/// Every message the client POSTs to the scripted server.
186+
posted: mpsc::UnboundedReceiver<Value>,
187+
/// Feeds the standalone GET stream.
188+
get_tx: mpsc::Sender<Value>,
189+
/// Feeds the SSE stream of the in-flight tools/list POST.
190+
post_tx: mpsc::Sender<Value>,
191+
/// In-flight tools/list call (response withheld until the test releases it).
192+
call: tokio::task::JoinHandle<Result<rmcp::model::ListToolsResult, rmcp::ServiceError>>,
193+
tools_list_id: Value,
194+
/// Sampling params seen by the client handler.
195+
sampled: mpsc::UnboundedReceiver<CreateMessageRequestParams>,
196+
}
197+
198+
/// Drive startup + one in-flight tools/list.
199+
async fn setup() -> Harness {
186200
let (get_tx, get_rx) = mpsc::channel(8);
187201
let (post_tx, post_rx) = mpsc::channel(8);
188202
let (posted_tx, mut posted_rx) = mpsc::unbounded_channel();
203+
let (sampled_tx, sampled_rx) = mpsc::unbounded_channel();
189204
let server = ScriptedServer {
190205
get_stream: Arc::new(Mutex::new(Some(get_rx))),
191206
post_stream: Arc::new(Mutex::new(Some(post_rx))),
@@ -195,9 +210,15 @@ async fn setup(
195210
server,
196211
StreamableHttpClientTransportConfig::with_uri("http://scripted/mcp"),
197212
);
198-
let client = serve_client_with_lifecycle(handler, transport, ClientLifecycleMode::Initialize)
199-
.await
200-
.expect("initialize against scripted server");
213+
let client = serve_client_with_lifecycle(
214+
SamplingClient {
215+
sampled: sampled_tx,
216+
},
217+
transport,
218+
ClientLifecycleMode::Initialize,
219+
)
220+
.await
221+
.expect("initialize against scripted server");
201222

202223
// initialize + notifications/initialized already posted during startup.
203224
assert_eq!(next_posted(&mut posted_rx).await["method"], "initialize");
@@ -213,7 +234,15 @@ async fn setup(
213234
assert_eq!(tools_list["method"], "tools/list");
214235
let tools_list_id = tools_list["id"].clone();
215236

216-
(client, posted_rx, get_tx, post_tx, call, tools_list_id)
237+
Harness {
238+
client,
239+
posted: posted_rx,
240+
get_tx,
241+
post_tx,
242+
call,
243+
tools_list_id,
244+
sampled: sampled_rx,
245+
}
217246
}
218247

219248
/// #1033 scenario 1: a restricted request on the standalone GET stream while
@@ -222,12 +251,11 @@ async fn setup(
222251
#[tokio::test]
223252
async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_flight()
224253
-> anyhow::Result<()> {
225-
let (client, mut posted_rx, get_tx, post_tx, call, tools_list_id) =
226-
setup(SamplingClient { on_sampling: None }).await;
254+
let mut h = setup().await;
227255

228-
get_tx.send(sampling_request(100)).await?;
256+
h.get_tx.send(sampling_request(100)).await?;
229257

230-
let rejection = next_posted(&mut posted_rx).await;
258+
let rejection = next_posted(&mut h.posted).await;
231259
assert_eq!(
232260
rejection["id"], 100,
233261
"reply to the sampling request: {rejection}"
@@ -238,25 +266,28 @@ async fn restricted_request_on_get_stream_rejected_while_unrelated_request_in_fl
238266
request in flight, got {rejection}"
239267
);
240268

241-
post_tx.send(tools_list_response(&tools_list_id)).await?;
242-
call.await??;
243-
client.cancel().await?;
269+
h.post_tx
270+
.send(tools_list_response(&h.tools_list_id))
271+
.await?;
272+
tokio::time::timeout(Duration::from_secs(5), h.call).await???;
273+
// Rejected means rejected: the handler must not ALSO have been invoked.
274+
assert!(
275+
h.sampled.try_recv().is_err(),
276+
"handler must not see the rejected sampling request"
277+
);
278+
tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??;
244279
Ok(())
245280
}
246281

247282
/// Positive twin: the same restricted request arriving on the SSE stream of
248283
/// the originating POST is dispatched to the handler and answered.
249284
#[tokio::test]
250285
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;
286+
let mut h = setup().await;
256287

257-
post_tx.send(sampling_request(200)).await?;
288+
h.post_tx.send(sampling_request(200)).await?;
258289

259-
let response = next_posted(&mut posted_rx).await;
290+
let response = next_posted(&mut h.posted).await;
260291
assert_eq!(
261292
response["id"], 200,
262293
"reply to the sampling request: {response}"
@@ -265,12 +296,14 @@ async fn restricted_request_on_originating_post_stream_is_dispatched() -> anyhow
265296
response["result"]["model"], "test-model",
266297
"request on the originating POST stream must reach the handler, got {response}"
267298
);
268-
tokio::time::timeout(Duration::from_secs(5), sampled_rx.recv())
299+
tokio::time::timeout(Duration::from_secs(5), h.sampled.recv())
269300
.await?
270301
.expect("handler invoked");
271302

272-
post_tx.send(tools_list_response(&tools_list_id)).await?;
273-
call.await??;
274-
client.cancel().await?;
303+
h.post_tx
304+
.send(tools_list_response(&h.tools_list_id))
305+
.await?;
306+
tokio::time::timeout(Duration::from_secs(5), h.call).await???;
307+
tokio::time::timeout(Duration::from_secs(5), h.client.cancel()).await??;
275308
Ok(())
276309
}

0 commit comments

Comments
 (0)