Skip to content

Commit 5258a25

Browse files
committed
feat: expose streamable http session id
1 parent f1ef2ec commit 5258a25

5 files changed

Lines changed: 127 additions & 4 deletions

File tree

crates/rmcp/src/service.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ use crate::{
5151
JsonRpcError, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Meta,
5252
NumberOrString, ProgressToken, RequestId,
5353
},
54-
transport::{DynamicTransportError, IntoTransport, Transport},
54+
transport::{DynamicTransportError, IntoTransport, Transport, TransportSessionIdHandle},
5555
};
5656
#[cfg(feature = "client")]
5757
mod client;
@@ -508,6 +508,7 @@ pub struct RunningService<R: ServiceRole, S: Service<R>> {
508508
handle: Option<tokio::task::JoinHandle<QuitReason>>,
509509
cancellation_token: CancellationToken,
510510
dg: DropGuard,
511+
session_id_handle: Option<TransportSessionIdHandle>,
511512
}
512513
impl<R: ServiceRole, S: Service<R>> Deref for RunningService<R, S> {
513514
type Target = Peer<R>;
@@ -530,6 +531,12 @@ impl<R: ServiceRole, S: Service<R>> RunningService<R, S> {
530531
pub fn cancellation_token(&self) -> RunningServiceCancellationToken {
531532
RunningServiceCancellationToken(self.cancellation_token.clone())
532533
}
534+
#[inline]
535+
pub fn session_id(&self) -> Option<Arc<str>> {
536+
self.session_id_handle
537+
.as_ref()
538+
.and_then(|handle| handle.session_id())
539+
}
533540

534541
/// Returns true if the service has been closed or cancelled.
535542
#[inline]
@@ -755,6 +762,7 @@ where
755762
let (sink_proxy_tx, mut sink_proxy_rx) =
756763
tokio::sync::mpsc::channel::<TxJsonRpcMessage<R>>(SINK_PROXY_BUFFER_SIZE);
757764
let peer_info = peer.peer_info();
765+
let session_id_handle = transport.session_id_handle();
758766
if R::IS_CLIENT {
759767
tracing::info!(?peer_info, "Service initialized as client");
760768
} else {
@@ -1094,5 +1102,6 @@ where
10941102
handle: Some(handle),
10951103
cancellation_token: ct.clone(),
10961104
dg: ct.drop_guard(),
1105+
session_id_handle,
10971106
}
10981107
}

crates/rmcp/src/transport.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,39 @@ where
145145

146146
/// Close the transport
147147
fn close(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send;
148+
149+
/// Returns a handle for reading the current transport session ID, when the
150+
/// transport protocol has one.
151+
fn session_id_handle(&self) -> Option<TransportSessionIdHandle> {
152+
None
153+
}
154+
155+
/// Returns the current transport session ID, when the transport protocol has one.
156+
fn session_id(&self) -> Option<Arc<str>> {
157+
self.session_id_handle()
158+
.and_then(|handle| handle.session_id())
159+
}
160+
}
161+
162+
/// Read-only provider for transports that negotiate a session ID.
163+
pub trait TransportSessionIdProvider: std::fmt::Debug + Send + Sync + 'static {
164+
fn session_id(&self) -> Option<Arc<str>>;
165+
}
166+
167+
/// Cloneable handle for observing a transport's current session ID.
168+
#[derive(Debug, Clone)]
169+
pub struct TransportSessionIdHandle {
170+
provider: Arc<dyn TransportSessionIdProvider>,
171+
}
172+
173+
impl TransportSessionIdHandle {
174+
pub fn new(provider: Arc<dyn TransportSessionIdProvider>) -> Self {
175+
Self { provider }
176+
}
177+
178+
pub fn session_id(&self) -> Option<Arc<str>> {
179+
self.provider.session_id()
180+
}
148181
}
149182

150183
pub trait IntoTransport<R, E, A>: Send + 'static

crates/rmcp/src/transport/streamable_http_client.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration};
1+
use std::{
2+
borrow::Cow,
3+
collections::HashMap,
4+
sync::{Arc, RwLock},
5+
time::Duration,
6+
};
27

38
use futures::{Stream, StreamExt, future::BoxFuture, stream::BoxStream};
49
use http::{HeaderName, HeaderValue};
@@ -16,13 +21,38 @@ use crate::{
1621
ServerResult,
1722
},
1823
transport::{
24+
TransportSessionIdHandle, TransportSessionIdProvider,
1925
common::client_side_sse::SseAutoReconnectStream,
2026
worker::{Worker, WorkerQuitReason, WorkerSendRequest, WorkerTransport},
2127
},
2228
};
2329

2430
type BoxedSseStream = BoxStream<'static, Result<Sse, SseError>>;
2531

32+
/// Cloneable read-only handle for the negotiated streamable HTTP session ID.
33+
#[derive(Debug, Clone, Default)]
34+
pub struct StreamableHttpClientSession {
35+
session_id: Arc<RwLock<Option<Arc<str>>>>,
36+
}
37+
38+
impl StreamableHttpClientSession {
39+
pub fn session_id(&self) -> Option<Arc<str>> {
40+
self.session_id.read().ok().and_then(|guard| guard.clone())
41+
}
42+
43+
fn set_session_id(&self, session_id: Option<Arc<str>>) {
44+
if let Ok(mut guard) = self.session_id.write() {
45+
*guard = session_id;
46+
}
47+
}
48+
}
49+
50+
impl TransportSessionIdProvider for StreamableHttpClientSession {
51+
fn session_id(&self) -> Option<Arc<str>> {
52+
self.session_id()
53+
}
54+
}
55+
2656
#[derive(Debug)]
2757
#[non_exhaustive]
2858
pub struct AuthRequiredError {
@@ -277,6 +307,7 @@ struct SessionCleanupInfo<C> {
277307
pub struct StreamableHttpClientWorker<C: StreamableHttpClient> {
278308
pub client: C,
279309
pub config: StreamableHttpClientTransportConfig,
310+
session: StreamableHttpClientSession,
280311
}
281312

282313
impl<C: StreamableHttpClient + Default> StreamableHttpClientWorker<C> {
@@ -287,13 +318,18 @@ impl<C: StreamableHttpClient + Default> StreamableHttpClientWorker<C> {
287318
uri: url.into(),
288319
..Default::default()
289320
},
321+
session: StreamableHttpClientSession::default(),
290322
}
291323
}
292324
}
293325

294326
impl<C: StreamableHttpClient> StreamableHttpClientWorker<C> {
295327
pub fn new(client: C, config: StreamableHttpClientTransportConfig) -> Self {
296-
Self { client, config }
328+
Self {
329+
client,
330+
config,
331+
session: StreamableHttpClientSession::default(),
332+
}
297333
}
298334
}
299335

@@ -447,6 +483,11 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
447483
fn err_join(e: tokio::task::JoinError) -> Self::Error {
448484
StreamableHttpError::TokioJoinError(e)
449485
}
486+
fn session_id_handle(&self) -> Option<TransportSessionIdHandle> {
487+
Some(TransportSessionIdHandle::new(Arc::new(
488+
self.session.clone(),
489+
)))
490+
}
450491
fn config(&self) -> super::worker::WorkerConfig {
451492
super::worker::WorkerConfig {
452493
name: Some("StreamableHttpClientWorker".into()),
@@ -505,6 +546,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
505546
}
506547
None
507548
};
549+
self.session.set_session_id(session_id.clone());
508550
// Extract the negotiated protocol version from the init response
509551
// and build a custom headers map that includes MCP-Protocol-Version
510552
// for all subsequent HTTP requests (per MCP 2025-06-18 spec).
@@ -684,6 +726,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
684726
streams.abort_all();
685727

686728
session_id = new_session_id;
729+
self.session.set_session_id(session_id.clone());
687730
protocol_headers = new_protocol_headers;
688731
session_cleanup_info =
689732
session_id.as_ref().map(|sid| SessionCleanupInfo {
@@ -872,6 +915,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
872915
}
873916
}
874917
}
918+
self.session.set_session_id(None);
875919

876920
loop_result
877921
}

crates/rmcp/src/transport/worker.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::{borrow::Cow, time::Duration};
33
use tokio_util::sync::CancellationToken;
44
use tracing::{Instrument, Level};
55

6-
use super::{IntoTransport, Transport};
6+
use super::{IntoTransport, Transport, TransportSessionIdHandle};
77
use crate::service::{RxJsonRpcMessage, ServiceRole, TxJsonRpcMessage};
88

99
#[derive(Debug, thiserror::Error)]
@@ -46,6 +46,9 @@ pub trait Worker: Sized + Send + 'static {
4646
type Role: ServiceRole;
4747
fn err_closed() -> Self::Error;
4848
fn err_join(e: tokio::task::JoinError) -> Self::Error;
49+
fn session_id_handle(&self) -> Option<TransportSessionIdHandle> {
50+
None
51+
}
4952
fn run(
5053
self,
5154
context: WorkerContext<Self>,
@@ -67,6 +70,7 @@ pub struct WorkerTransport<W: Worker> {
6770
join_handle: Option<tokio::task::JoinHandle<Result<(), WorkerQuitReason<W::Error>>>>,
6871
_drop_guard: tokio_util::sync::DropGuard,
6972
ct: CancellationToken,
73+
session_id_handle: Option<TransportSessionIdHandle>,
7074
}
7175

7276
#[non_exhaustive]
@@ -96,12 +100,21 @@ impl<W: Worker> WorkerTransport<W> {
96100
pub fn cancel_token(&self) -> CancellationToken {
97101
self.ct.clone()
98102
}
103+
pub fn session_id_handle(&self) -> Option<TransportSessionIdHandle> {
104+
self.session_id_handle.clone()
105+
}
106+
pub fn session_id(&self) -> Option<std::sync::Arc<str>> {
107+
self.session_id_handle
108+
.as_ref()
109+
.and_then(|handle| handle.session_id())
110+
}
99111
pub fn spawn(worker: W) -> Self {
100112
Self::spawn_with_ct(worker, CancellationToken::new())
101113
}
102114
pub fn spawn_with_ct(worker: W, transport_task_ct: CancellationToken) -> Self {
103115
let config = worker.config();
104116
let worker_name = config.name;
117+
let session_id_handle = worker.session_id_handle();
105118
let (to_transport_tx, from_handler_rx) =
106119
tokio::sync::mpsc::channel::<WorkerSendRequest<W>>(config.channel_buffer_capacity);
107120
let (to_handler_tx, from_transport_rx) =
@@ -145,6 +158,7 @@ impl<W: Worker> WorkerTransport<W> {
145158
join_handle: Some(join_handle),
146159
ct: transport_task_ct.clone(),
147160
_drop_guard: transport_task_ct.drop_guard(),
161+
session_id_handle,
148162
}
149163
}
150164
}
@@ -214,4 +228,8 @@ impl<W: Worker> Transport<W::Role> for WorkerTransport<W> {
214228
Ok(())
215229
}
216230
}
231+
232+
fn session_id_handle(&self) -> Option<TransportSessionIdHandle> {
233+
WorkerTransport::session_id_handle(self)
234+
}
217235
}

crates/rmcp/tests/test_streamable_http_session_store.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,22 @@ async fn test_session_state_persisted_to_store() -> anyhow::Result<()> {
108108
let transport = StreamableHttpClientTransport::from_config(
109109
StreamableHttpClientTransportConfig::with_uri(format!("http://{addr}/mcp")),
110110
);
111+
assert!(
112+
transport.session_id().is_none(),
113+
"session ID should not be set before initialization"
114+
);
115+
let session_id_handle = transport
116+
.session_id_handle()
117+
.expect("streamable HTTP transport should expose a session ID handle");
111118
let client = ().serve(transport).await?;
119+
let client_session_id = client
120+
.session_id()
121+
.expect("session ID should be exposed after initialization");
122+
assert_eq!(
123+
session_id_handle.session_id().as_deref(),
124+
Some(client_session_id.as_ref()),
125+
"transport handle and running service should expose the same session ID"
126+
);
112127

113128
// Make a real request so the session is fully active.
114129
let _resources = client.list_all_resources().await?;
@@ -122,6 +137,10 @@ async fn test_session_state_persisted_to_store() -> anyhow::Result<()> {
122137

123138
// Verify the stored state contains the expected client info.
124139
let entries = store.0.read().await;
140+
assert!(
141+
entries.contains_key(client_session_id.as_ref()),
142+
"exposed session ID should match the persisted store key"
143+
);
125144
let state = entries.values().next().expect("store entry should exist");
126145
assert_eq!(
127146
state.initialize_params.client_info.name, "rmcp",

0 commit comments

Comments
 (0)