contextvm_sdk/rmcp_transport/open_stream.rs
1//! CEP-41: the ergonomic `call_tool_stream` consumer API.
2//!
3//! Ports `sdk/src/transport/call-tool-stream.ts`. Pairs a normal rmcp
4//! `tools/call` with its CEP-41 open stream: the call is issued with
5//! progress-aware request options (so rmcp stamps a `progressToken` and arms the
6//! reset-on-progress watcher), and the transport binds the SDK-stamped token to a
7//! reader [`OpenStreamSession`] the moment the request is published (via the
8//! [`ClientOpenStreamHandle`] obtained before the transport is served).
9//!
10//! One `tools/call` yields two outputs: the live chunk [`stream`](ToolStreamCall::stream)
11//! and the eventual final [`result`](ToolStreamCall::result) — exactly the CEP-41
12//! supplement-not-replace semantics.
13
14use std::time::Duration;
15
16use futures::future::BoxFuture;
17use rmcp::model::{CallToolRequestParams, CallToolResult};
18use rmcp::service::{Peer, PeerRequestOptions, ServiceError};
19use rmcp::RoleClient;
20use tokio::task::{JoinError, JoinHandle};
21
22use crate::core::error::{Error, Result};
23use crate::transport::client::ClientOpenStreamHandle;
24use crate::transport::open_stream::OpenStreamSession;
25
26use super::progress::PeerRequestOptionsExt;
27
28type AbortFn = Box<dyn Fn(Option<String>) -> BoxFuture<'static, ()> + Send + Sync>;
29
30/// A live CEP-41 tool call: the incremental chunk [`stream`](Self::stream), the
31/// eventual final [`result`](Self::result), and an [`abort`](Self::abort) handle.
32pub struct ToolStreamCall {
33 /// The stringified `progressToken` correlating the call and its stream.
34 pub progress_token: String,
35 /// The async stream of payload chunks (`impl Stream<Item = Result<String, OpenStreamError>>`).
36 pub stream: OpenStreamSession,
37 /// The final `CallToolResult`, resolving after the stream closes (deferral).
38 ///
39 /// A **flat** result: the spawned-task (`JoinError`) and rmcp (`ServiceError`)
40 /// failures are folded into [`crate::Error`], so consumers `await` once rather
41 /// than unwrapping a nested `Result`.
42 pub result: BoxFuture<'static, Result<CallToolResult>>,
43 abort_fn: AbortFn,
44}
45
46impl ToolStreamCall {
47 /// Consumer cancel: publish an `abort` frame to the server (so its writer
48 /// aborts), finalize the local stream, and free the reader registry slot.
49 pub async fn abort(&self, reason: Option<String>) {
50 (self.abort_fn)(reason).await;
51 }
52}
53
54impl std::fmt::Debug for ToolStreamCall {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("ToolStreamCall")
57 .field("progress_token", &self.progress_token)
58 .finish_non_exhaustive()
59 }
60}
61
62/// Build the progress-aware [`PeerRequestOptions`] for an open-stream call.
63///
64/// Idle timeout covers a full keepalive cycle (idle → probe → close-grace) so the
65/// rmcp request is never failed before the open-stream keepalive would have
66/// aborted a genuinely-dead stream; `reset_timeout_on_progress` re-arms it on
67/// every forwarded chunk/keepalive frame. A hard lifetime cap is applied **only**
68/// when `max_total_timeout_ms` is set — never the CEP-22 oversized default (an
69/// open stream may legitimately run unbounded).
70fn open_stream_request_options(handle: &ClientOpenStreamHandle) -> PeerRequestOptions {
71 let config = handle.config();
72 let idle_ms = config
73 .idle_timeout_ms
74 .saturating_add(config.probe_timeout_ms)
75 .saturating_add(config.close_grace_period_ms)
76 .max(1);
77 let mut options = PeerRequestOptions::with_timeout(Duration::from_millis(idle_ms))
78 .reset_timeout_on_progress();
79 if let Some(max_total_ms) = config.max_total_timeout_ms {
80 options = options.with_max_total_timeout(Duration::from_millis(max_total_ms));
81 }
82 options
83}
84
85/// Call an MCP tool and return the paired CEP-41 [`ToolStreamCall`].
86///
87/// The reader session is registered **before** the request is published (the
88/// placeholder is bound synchronously inside the transport's `send`), so no
89/// inbound chunk can race ahead of it. The call itself runs on a spawned task so
90/// this returns as soon as the stream handle is available — long before the final
91/// result settles.
92pub async fn call_tool_stream(
93 peer: &Peer<RoleClient>,
94 transport: &ClientOpenStreamHandle,
95 params: CallToolRequestParams,
96) -> Result<ToolStreamCall> {
97 // Serialize the placeholder push→bind window so two concurrent
98 // `call_tool_stream` calls cannot cross their FIFO placeholders against the
99 // tokens rmcp stamps (the transport binds by FIFO order, but rmcp/worker order
100 // is independent). At most one placeholder is ever unbound while this is held;
101 // it is released the moment this call's session binds, so the streams
102 // themselves still run fully concurrently.
103 let bind_guard = transport.bind_lock().clone().lock_owned().await;
104
105 // 1. Register the placeholder for the reader session (resolved by `send`).
106 let pending = transport.prepare_outbound();
107
108 // 2. Build progress-aware options (rmcp stamps the token + arms the watcher).
109 let options = open_stream_request_options(transport);
110
111 // 3. Issue the call on a spawned task so we can hand back the stream first.
112 let peer = peer.clone();
113 let mut result_handle: JoinHandle<std::result::Result<CallToolResult, ServiceError>> =
114 tokio::spawn(async move { peer.call_tool_with_options(params, options).await });
115
116 // 4. Bind the reader session. Race `pending` against the call itself: if the
117 // call settles BEFORE the transport binds the placeholder (e.g. rmcp
118 // rejects the request before publishing), `pending` would never resolve and
119 // the bind lock would deadlock every later `call_tool_stream`. `biased`
120 // prefers the bind (a successful call always publishes — and binds — first,
121 // so the call-settled arm fires only on a pre-publish failure).
122 let (progress_token, stream) = tokio::select! {
123 biased;
124 bound = pending => match bound {
125 Ok(Ok(pair)) => pair,
126 Ok(Err(error)) => {
127 drop(bind_guard);
128 result_handle.abort();
129 return Err(error);
130 }
131 Err(_) => {
132 // The transport closed (or dropped the placeholder) before binding.
133 drop(bind_guard);
134 result_handle.abort();
135 return Err(Error::Transport(
136 "transport closed before the outbound open-stream session was bound"
137 .to_string(),
138 ));
139 }
140 },
141 settled = &mut result_handle => {
142 // The call finished without ever binding a session — drop the orphaned
143 // placeholder so the next `tools/call` is not mis-bound to it, then
144 // surface the (flattened) error.
145 transport.cancel_outbound();
146 drop(bind_guard);
147 return Err(match flatten_call_result(settled) {
148 Err(error) => error,
149 Ok(_) => Error::Other(
150 "open-stream tool call completed without establishing a stream".to_string(),
151 ),
152 });
153 }
154 };
155 drop(bind_guard);
156
157 // 5. Build the abort handle (publish abort + finalize + free the slot).
158 let registry = transport.registry();
159 let abort_session = stream.clone();
160 let abort_token = progress_token.clone();
161 let abort_fn: AbortFn = Box::new(move |reason: Option<String>| {
162 let registry = registry.clone();
163 let session = abort_session.clone();
164 let token = abort_token.clone();
165 Box::pin(async move {
166 // Publish the `abort` frame to the server + finalize locally.
167 session.abort(reason.clone()).await;
168 // Free the concurrency slot + run any hook (idempotent re-finalize).
169 registry.lock().await.consumer_abort(&token, reason).await;
170 })
171 });
172
173 // 6. Flatten the spawned call into a single-`await` future (JoinError +
174 // ServiceError folded into `crate::Error`).
175 let result: BoxFuture<'static, Result<CallToolResult>> =
176 Box::pin(async move { flatten_call_result(result_handle.await) });
177
178 Ok(ToolStreamCall {
179 progress_token,
180 stream,
181 result,
182 abort_fn,
183 })
184}
185
186/// Fold the doubly-nested `call_tool_with_options` outcome (`JoinError` outside,
187/// `ServiceError` inside) into a flat [`crate::Error`].
188fn flatten_call_result(
189 settled: std::result::Result<std::result::Result<CallToolResult, ServiceError>, JoinError>,
190) -> Result<CallToolResult> {
191 match settled {
192 Ok(Ok(result)) => Ok(result),
193 Ok(Err(service_error)) => Err(Error::Transport(service_error.to_string())),
194 Err(join_error) => Err(Error::Other(format!(
195 "call_tool_stream task failed: {join_error}"
196 ))),
197 }
198}