Skip to content

Commit e247b18

Browse files
committed
feat(transport): record inbound stream origin on streamable HTTP client (#1033)
The worker attaches an InboundStreamOrigin extension to each inbound server request: Unassociated for the standalone GET stream, OutboundRequest(id) for a POST's SSE stream. Mirror of the OriginatingRequestId marker used by the server side.
1 parent ae12a9e commit e247b18

2 files changed

Lines changed: 102 additions & 5 deletions

File tree

crates/rmcp/src/service.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,23 @@ pub(crate) fn in_request_handler_scope() -> bool {
231231
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
232232
pub struct OriginatingRequestId(pub RequestId);
233233

234+
/// Marker in an inbound request's non-serialized [`Extensions`] recording
235+
/// which HTTP response stream it arrived on (SEP-2260): the receive-side
236+
/// mirror of [`OriginatingRequestId`]. Attached by the streamable HTTP
237+
/// client transport; read by the service layer to enforce the client
238+
/// receive-side association check. Never on the wire (SEP-2260 defines no
239+
/// wire field), so transports that serialize messages cannot convey it and
240+
/// the service layer falls back to the coarse in-flight check.
241+
#[derive(Debug, Clone, PartialEq, Eq)]
242+
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
243+
pub enum InboundStreamOrigin {
244+
/// The standalone GET stream, or a POST response stream not tied to an
245+
/// outbound request.
246+
Unassociated,
247+
/// The SSE response stream of the POST that carried this outbound request.
248+
OutboundRequest(RequestId),
249+
}
250+
234251
pub type TxJsonRpcMessage<R> =
235252
JsonRpcMessage<<R as ServiceRole>::Req, <R as ServiceRole>::Resp, <R as ServiceRole>::Not>;
236253
pub type RxJsonRpcMessage<R> = JsonRpcMessage<

crates/rmcp/src/transport/streamable_http_client.rs

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ use super::common::client_side_sse::{
1919
use crate::{
2020
RoleClient,
2121
model::{
22-
ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetMeta,
22+
ClientJsonRpcMessage, ClientNotification, ClientRequest, ErrorData, GetExtensions, GetMeta,
2323
InitializedNotification, JsonObject, ProtocolVersion, RequestId, ServerJsonRpcMessage,
2424
ServerResult,
2525
},
26+
service::InboundStreamOrigin,
2627
transport::{
2728
common::{client_side_sse::SseAutoReconnectStream, mcp_headers},
2829
worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport},
@@ -617,6 +618,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
617618
+ Send
618619
+ 'static,
619620
sse_worker_tx: tokio::sync::mpsc::Sender<ServerJsonRpcMessage>,
621+
origin: InboundStreamOrigin,
620622
close_on_response: bool,
621623
ct: CancellationToken,
622624
) -> Result<(), StreamableHttpError<C::Error>> {
@@ -631,9 +633,14 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
631633
break;
632634
}
633635
};
634-
let Some(message) = message.transpose()? else {
636+
let Some(mut message) = message.transpose()? else {
635637
break;
636638
};
639+
// SEP-2260: record which stream this request arrived on so the
640+
// service layer can enforce receive-side request association.
641+
if let ServerJsonRpcMessage::Request(request) = &mut message {
642+
request.request.extensions_mut().insert(origin.clone());
643+
}
637644
let is_response = matches!(
638645
message,
639646
ServerJsonRpcMessage::Response(_) | ServerJsonRpcMessage::Error(_)
@@ -701,6 +708,7 @@ impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
701708
Self::execute_sse_stream(
702709
sse_stream,
703710
sse_worker_tx,
711+
InboundStreamOrigin::Unassociated,
704712
false,
705713
transport_task_ct.child_token(),
706714
)
@@ -1265,9 +1273,18 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
12651273
);
12661274
}
12671275
let stream_tx = sse_worker_tx.clone();
1276+
let origin = match &stream_request_id {
1277+
Some(id) => {
1278+
InboundStreamOrigin::OutboundRequest(
1279+
id.clone(),
1280+
)
1281+
}
1282+
None => InboundStreamOrigin::Unassociated,
1283+
};
12681284
streams.spawn(async move {
12691285
let result = Self::execute_sse_stream(
1270-
sse_stream, stream_tx, true, stream_ct,
1286+
sse_stream, stream_tx, origin, true,
1287+
stream_ct,
12711288
)
12721289
.await;
12731290
(stream_request_id, result)
@@ -1324,9 +1341,13 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
13241341
.insert(request_id.clone(), stream_ct.clone());
13251342
}
13261343
let stream_tx = sse_worker_tx.clone();
1344+
let origin = match &stream_request_id {
1345+
Some(id) => InboundStreamOrigin::OutboundRequest(id.clone()),
1346+
None => InboundStreamOrigin::Unassociated,
1347+
};
13271348
streams.spawn(async move {
13281349
let result = Self::execute_sse_stream(
1329-
sse_stream, stream_tx, true, stream_ct,
1350+
sse_stream, stream_tx, origin, true, stream_ct,
13301351
)
13311352
.await;
13321353
(stream_request_id, result)
@@ -1751,7 +1772,66 @@ mod tests {
17511772
use serde_json::json;
17521773

17531774
use super::*;
1754-
use crate::model::{ListToolsResult, NumberOrString, ServerResult, Tool};
1775+
use crate::{
1776+
model::{
1777+
GetExtensions, ListToolsResult, NumberOrString, ServerRequest, ServerResult, Tool,
1778+
},
1779+
service::InboundStreamOrigin,
1780+
};
1781+
1782+
#[expect(
1783+
deprecated,
1784+
reason = "sampling (SEP-2577) is still a representative server request"
1785+
)]
1786+
fn sampling_request_message(id: i64) -> ServerJsonRpcMessage {
1787+
use crate::model::{CreateMessageRequest, CreateMessageRequestParams, SamplingMessage};
1788+
ServerJsonRpcMessage::request(
1789+
ServerRequest::CreateMessageRequest(CreateMessageRequest::new(
1790+
CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 16),
1791+
)),
1792+
NumberOrString::Number(id),
1793+
)
1794+
}
1795+
1796+
#[tokio::test]
1797+
async fn execute_sse_stream_marks_inbound_requests_with_origin() {
1798+
for origin in [
1799+
InboundStreamOrigin::Unassociated,
1800+
InboundStreamOrigin::OutboundRequest(RequestId::Number(3)),
1801+
] {
1802+
let response = ServerJsonRpcMessage::response(
1803+
ServerResult::ListToolsResult(ListToolsResult::default()),
1804+
NumberOrString::Number(1),
1805+
);
1806+
let stream = futures::stream::iter([Ok(sampling_request_message(9)), Ok(response)]);
1807+
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
1808+
StreamableHttpClientWorker::<StatelessReconnectClient>::execute_sse_stream(
1809+
stream,
1810+
tx,
1811+
origin.clone(),
1812+
false,
1813+
CancellationToken::new(),
1814+
)
1815+
.await
1816+
.expect("stream completes");
1817+
1818+
let ServerJsonRpcMessage::Request(request) =
1819+
rx.recv().await.expect("request forwarded")
1820+
else {
1821+
panic!("expected request first");
1822+
};
1823+
assert_eq!(
1824+
request.request.extensions().get::<InboundStreamOrigin>(),
1825+
Some(&origin),
1826+
"inbound requests must carry their stream origin"
1827+
);
1828+
// Responses are correlated by JSON-RPC id; no marker needed or added.
1829+
assert!(matches!(
1830+
rx.recv().await.expect("response forwarded"),
1831+
ServerJsonRpcMessage::Response(_)
1832+
));
1833+
}
1834+
}
17551835

17561836
type ReconnectAttempt = (Option<String>, Option<String>);
17571837

0 commit comments

Comments
 (0)