Skip to main content

objectiveai_sdk/http/
notifier.rs

1//! Client-side handle for sending `client_request::Notify` frames
2//! over a streaming WS connection and awaiting their matching
3//! `client_response::Response` replies.
4//!
5//! Returned from every `*_streaming_ws` method alongside the chunk
6//! Stream. Drop the Notifier to relinquish the write half — when
7//! both the Notifier and the Stream are dropped, the demux task
8//! exits and the WS closes cleanly.
9
10use crate::client_objectiveai_mcp::{
11    client_request, client_response, server_response,
12};
13use futures::SinkExt;
14use futures::stream::SplitSink;
15use std::sync::Arc;
16use tokio::net::TcpStream;
17use tokio::sync::{Mutex, oneshot};
18use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite};
19
20/// Shared sender half of a split WebSocket. Both the Notifier and
21/// the demux task (which writes `server_response` frames after
22/// handler dispatch) hold a clone; the mutex serializes writes so
23/// frames don't interleave on the wire.
24pub(crate) type SharedSink = Arc<
25    Mutex<
26        SplitSink<
27            WebSocketStream<MaybeTlsStream<TcpStream>>,
28            tungstenite::Message,
29        >,
30    >,
31>;
32
33/// Per-connection registry of outstanding notify ids and the
34/// oneshot senders the demux task fulfills when the matching
35/// `client_response::Response` arrives.
36pub(crate) type PendingNotifies =
37    Arc<dashmap::DashMap<String, oneshot::Sender<client_response::Response>>>;
38
39/// Client-side handle for sending notifies to a running agent
40/// completion (or any other streaming endpoint) over the same WS
41/// that carries the chunk stream.
42///
43/// Multiple in-flight notifies are supported; each gets a unique id
44/// and parks its own oneshot. Calls are independent and can run
45/// concurrently from multiple tasks.
46#[derive(Clone)]
47pub struct Notifier {
48    sink: SharedSink,
49    pending: PendingNotifies,
50}
51
52impl Notifier {
53    pub(crate) fn new(sink: SharedSink, pending: PendingNotifies) -> Self {
54        Self { sink, pending }
55    }
56
57    /// Forward a `notifications/{tools,resources}/list_changed`
58    /// observation from an upstream `mcp::Connection` up to the API,
59    /// which will fan it out as an SSE event on every matching
60    /// per-MCP GET stream — `/objectiveai` or
61    /// `/{owner}/{name}/{ver}/{mcp}`, subscribed under the
62    /// per-agent `response_id` + matching `McpKind`.
63    ///
64    /// Returns `Ok(())` if the server accepted the notify. Returns
65    /// the server-supplied `code + message` if the server replied
66    /// with an `Error` variant. Returns
67    /// [`super::HttpError::NotifyChannelClosed`] if the WS was torn
68    /// down before the reply arrived.
69    pub async fn notify_list_changed(
70        &self,
71        change: client_request::McpListChanged,
72    ) -> Result<(), super::HttpError> {
73        match self
74            .send_raw(client_request::Payload::McpListChanged(change))
75            .await?
76        {
77            client_response::Response::Ok { .. } => Ok(()),
78            client_response::Response::Error { code, message, .. } => {
79                Err(super::HttpError::NotifyRejected { code, message })
80            }
81            // This call only sends `McpListChanged`, whose reply is
82            // `Ok`/`Error`. The MCP-op result variants
83            // (`ListTools`/`CallTool`/...) are replies to other requests
84            // and shouldn't arrive here (responses correlate by id).
85            _ => Err(super::HttpError::NotifyRejected {
86                code: 0,
87                message: serde_json::Value::String(
88                    "unexpected reply variant for notify".to_string(),
89                ),
90            }),
91        }
92    }
93
94    /// Run the proxy's aggregated `tools/list` for `response_id` over
95    /// this WS and return the proxy's verbatim
96    /// [`JsonRpcResult`](crate::client_objectiveai_mcp::server_response::JsonRpcResult).
97    /// A server-level rejection (unknown / banned `response_id`) is
98    /// folded into `JsonRpcResult::Err` so callers see a single
99    /// MCP-shaped outcome; only transport failures surface as `Err`.
100    pub async fn list_tools(
101        &self,
102        response_id: String,
103        name: Option<String>,
104        params: crate::mcp::tool::ListToolsRequest,
105    ) -> Result<
106        server_response::JsonRpcResult<crate::mcp::tool::ListToolsResult>,
107        super::HttpError,
108    > {
109        match self
110            .send_raw(client_request::Payload::ListTools {
111                response_id,
112                name,
113                params,
114            })
115            .await?
116        {
117            client_response::Response::ListTools { result, .. } => Ok(result),
118            other => Self::fold_unexpected(other),
119        }
120    }
121
122    /// Run the proxy's aggregated `tools/call` for `response_id` over
123    /// this WS (routes by tool-name prefix to the owning upstream;
124    /// does NOT consult the queue delegate). See [`Self::list_tools`]
125    /// for the error-folding contract.
126    pub async fn call_tool(
127        &self,
128        response_id: String,
129        params: crate::mcp::tool::CallToolRequestParams,
130    ) -> Result<
131        server_response::JsonRpcResult<crate::mcp::tool::CallToolResult>,
132        super::HttpError,
133    > {
134        match self
135            .send_raw(client_request::Payload::CallTool {
136                response_id,
137                params,
138            })
139            .await?
140        {
141            client_response::Response::CallTool { result, .. } => Ok(result),
142            other => Self::fold_unexpected(other),
143        }
144    }
145
146    /// Run the proxy's aggregated `resources/list` for `response_id`
147    /// over this WS. See [`Self::list_tools`] for the error contract.
148    pub async fn list_resources(
149        &self,
150        response_id: String,
151        name: Option<String>,
152        params: crate::mcp::resource::ListResourcesRequest,
153    ) -> Result<
154        server_response::JsonRpcResult<crate::mcp::resource::ListResourcesResult>,
155        super::HttpError,
156    > {
157        match self
158            .send_raw(client_request::Payload::ListResources {
159                response_id,
160                name,
161                params,
162            })
163            .await?
164        {
165            client_response::Response::ListResources { result, .. } => {
166                Ok(result)
167            }
168            other => Self::fold_unexpected(other),
169        }
170    }
171
172    /// List the proxy's connected upstream MCP servers for `response_id`
173    /// over this WS (a proxy-local aggregate). See [`Self::list_tools`]
174    /// for the error contract.
175    pub async fn list_servers(
176        &self,
177        response_id: String,
178    ) -> Result<
179        server_response::JsonRpcResult<crate::mcp::server::ListServersResult>,
180        super::HttpError,
181    > {
182        match self
183            .send_raw(client_request::Payload::ListServers { response_id })
184            .await?
185        {
186            client_response::Response::ListServers { result, .. } => Ok(result),
187            other => Self::fold_unexpected(other),
188        }
189    }
190
191    /// Run the proxy's `resources/read` for `response_id` over this WS
192    /// (routes by URI prefix). See [`Self::list_tools`] for the error
193    /// contract.
194    pub async fn read_resource(
195        &self,
196        response_id: String,
197        params: crate::mcp::resource::ReadResourceRequestParams,
198    ) -> Result<
199        server_response::JsonRpcResult<crate::mcp::resource::ReadResourceResult>,
200        super::HttpError,
201    > {
202        match self
203            .send_raw(client_request::Payload::ReadResource {
204                response_id,
205                params,
206            })
207            .await?
208        {
209            client_response::Response::ReadResource { result, .. } => {
210                Ok(result)
211            }
212            other => Self::fold_unexpected(other),
213        }
214    }
215
216    /// Map a non-matching reply for an MCP-op request into the
217    /// `JsonRpcResult<R>` the caller expects:
218    ///
219    /// - generic `Error { code, message }` — a server-level rejection
220    ///   (e.g. unknown / banned `response_id`) — folds into
221    ///   `JsonRpcResult::Err` so every server-originated outcome is one
222    ///   MCP-shaped value.
223    /// - any other variant is a correlation/protocol bug (a reply for a
224    ///   different request kind reached this oneshot) → `NotifyRejected`.
225    fn fold_unexpected<R>(
226        other: client_response::Response,
227    ) -> Result<server_response::JsonRpcResult<R>, super::HttpError> {
228        match other {
229            client_response::Response::Error { code, message, .. } => {
230                let message = match message {
231                    serde_json::Value::String(s) => s,
232                    other => other.to_string(),
233                };
234                Ok(server_response::JsonRpcResult::Err {
235                    code: code as i64,
236                    message,
237                    data: None,
238                })
239            }
240            _ => Err(super::HttpError::NotifyRejected {
241                code: 0,
242                message: serde_json::Value::String(
243                    "unexpected reply variant for mcp request".to_string(),
244                ),
245            }),
246        }
247    }
248
249    /// Common send-and-await body: assign a correlation id, park a
250    /// oneshot in `pending`, write the framed request, and return the
251    /// raw [`client_response::Response`] the demux task routes back by
252    /// id. Callers interpret the variant.
253    async fn send_raw(
254        &self,
255        payload: client_request::Payload,
256    ) -> Result<client_response::Response, super::HttpError> {
257        let id = uuid::Uuid::new_v4().to_string();
258        let (tx, rx) = oneshot::channel();
259        self.pending.insert(id.clone(), tx);
260
261        let request = client_request::Request {
262            id: id.clone(),
263            payload,
264        };
265        let frame = match serde_json::to_string(&request) {
266            Ok(s) => s,
267            Err(e) => {
268                self.pending.remove(&id);
269                return Err(super::HttpError::NotifySerialize(e));
270            }
271        };
272
273        {
274            let mut guard = self.sink.lock().await;
275            if let Err(e) =
276                guard.send(tungstenite::Message::Text(frame.into())).await
277            {
278                drop(guard);
279                self.pending.remove(&id);
280                return Err(super::HttpError::NotifySend(e));
281            }
282        }
283
284        match rx.await {
285            Ok(r) => Ok(r),
286            Err(_) => Err(super::HttpError::NotifyChannelClosed),
287        }
288    }
289}