Skip to main content

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}