Skip to content

Commit 0ddd262

Browse files
committed
test: SEP-2260 origin marker survives SSE resumption (#1033)
A POST SSE stream resumed via GET + Last-Event-ID (SEP-1699) reconnects beneath execute_sse_stream, so requests replayed after a resume keep their OutboundRequest origin. Pins the layering invariant: hoisting reconnection above the marker attach point would wrongly reject associated requests with -32602.
1 parent 76f2989 commit 0ddd262

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

crates/rmcp/src/transport/streamable_http_client.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,132 @@ mod tests {
19301930
);
19311931
}
19321932

1933+
#[derive(Clone, Default)]
1934+
struct ResumedRequestClient {
1935+
reconnects: Arc<Mutex<Vec<ReconnectAttempt>>>,
1936+
}
1937+
1938+
impl StreamableHttpClient for ResumedRequestClient {
1939+
type Error = std::io::Error;
1940+
1941+
async fn post_message(
1942+
&self,
1943+
_uri: Arc<str>,
1944+
_message: ClientJsonRpcMessage,
1945+
_session_id: Option<Arc<str>>,
1946+
_auth_header: Option<String>,
1947+
_custom_headers: HashMap<HeaderName, HeaderValue>,
1948+
) -> Result<StreamableHttpPostResponse, StreamableHttpError<Self::Error>> {
1949+
Err(StreamableHttpError::UnexpectedServerResponse(
1950+
"unexpected POST".into(),
1951+
))
1952+
}
1953+
1954+
async fn delete_session(
1955+
&self,
1956+
_uri: Arc<str>,
1957+
_session_id: Arc<str>,
1958+
_auth_header: Option<String>,
1959+
_custom_headers: HashMap<HeaderName, HeaderValue>,
1960+
) -> Result<(), StreamableHttpError<Self::Error>> {
1961+
Ok(())
1962+
}
1963+
1964+
async fn get_stream(
1965+
&self,
1966+
_uri: Arc<str>,
1967+
session_id: Option<Arc<str>>,
1968+
last_event_id: Option<String>,
1969+
_auth_header: Option<String>,
1970+
_custom_headers: HashMap<HeaderName, HeaderValue>,
1971+
) -> Result<BoxedSseStream, StreamableHttpError<Self::Error>> {
1972+
self.reconnects
1973+
.lock()
1974+
.expect("lock reconnects")
1975+
.push((session_id.map(|id| id.to_string()), last_event_id));
1976+
let request = sampling_request_message(9);
1977+
let response = ServerJsonRpcMessage::response(
1978+
ServerResult::ListToolsResult(ListToolsResult::default()),
1979+
NumberOrString::Number(1),
1980+
);
1981+
// Stay open after the response, like a live connection, so the
1982+
// post-response drain in `execute_sse_stream` doesn't trigger
1983+
// further reconnects.
1984+
Ok(futures::stream::iter([request, response].map(|message| {
1985+
Ok(Sse {
1986+
event: None,
1987+
data: Some(serde_json::to_string(&message).expect("serialize message")),
1988+
id: None,
1989+
retry: None,
1990+
})
1991+
}))
1992+
.chain(futures::stream::pending())
1993+
.boxed())
1994+
}
1995+
}
1996+
1997+
/// SEP-1699 resumes a broken POST SSE stream via GET + Last-Event-ID
1998+
/// beneath `execute_sse_stream`, so the SEP-2260 origin marker must span
1999+
/// resumes; if reconnection were hoisted above the marker attach point,
2000+
/// replayed associated requests would be wrongly rejected with -32602.
2001+
#[tokio::test]
2002+
async fn resumed_post_stream_requests_keep_outbound_origin() {
2003+
let initial = futures::stream::iter([Ok(Sse {
2004+
event: None,
2005+
data: None,
2006+
id: Some("e1".into()),
2007+
retry: Some(0),
2008+
})])
2009+
.boxed();
2010+
let client = ResumedRequestClient::default();
2011+
let reconnects = client.reconnects.clone();
2012+
let sse_stream =
2013+
StreamableHttpClientWorker::<ResumedRequestClient>::response_sse_to_jsonrpc(
2014+
initial,
2015+
None,
2016+
client,
2017+
Arc::from("http://localhost/mcp"),
2018+
None,
2019+
HashMap::new(),
2020+
DEFAULT_MAX_SSE_EVENT_SIZE,
2021+
Arc::new(ExponentialBackoff {
2022+
max_times: Some(1),
2023+
base_duration: Duration::ZERO,
2024+
}),
2025+
);
2026+
2027+
let origin = InboundStreamOrigin::OutboundRequest(RequestId::Number(3));
2028+
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
2029+
StreamableHttpClientWorker::<ResumedRequestClient>::execute_sse_stream(
2030+
sse_stream,
2031+
tx,
2032+
origin.clone(),
2033+
true,
2034+
CancellationToken::new(),
2035+
)
2036+
.await
2037+
.expect("stream completes");
2038+
2039+
assert_eq!(
2040+
reconnects.lock().expect("lock reconnects").as_slice(),
2041+
&[(None, Some("e1".into()))],
2042+
"the request must arrive on the resumed connection"
2043+
);
2044+
let ServerJsonRpcMessage::Request(request) = rx.recv().await.expect("request forwarded")
2045+
else {
2046+
panic!("expected request first");
2047+
};
2048+
assert_eq!(
2049+
request.request.extensions().get::<InboundStreamOrigin>(),
2050+
Some(&origin),
2051+
"origin marker must survive SSE resumption"
2052+
);
2053+
assert!(matches!(
2054+
rx.recv().await.expect("response forwarded"),
2055+
ServerJsonRpcMessage::Response(_)
2056+
));
2057+
}
2058+
19332059
fn tool(name: &'static str, annotation: serde_json::Value) -> Tool {
19342060
let schema = json!({
19352061
"type": "object",

0 commit comments

Comments
 (0)