Skip to content

Commit 05e782c

Browse files
committed
test: origin marker survives SSE resumption per SEP-2260 (#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 bdeaa3e commit 05e782c

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
@@ -1948,6 +1948,132 @@ mod tests {
19481948
);
19491949
}
19501950

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

0 commit comments

Comments
 (0)