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        params: crate::mcp::tool::ListToolsRequest,
104    ) -> Result<
105        server_response::JsonRpcResult<crate::mcp::tool::ListToolsResult>,
106        super::HttpError,
107    > {
108        match self
109            .send_raw(client_request::Payload::ListTools {
110                response_id,
111                params,
112            })
113            .await?
114        {
115            client_response::Response::ListTools { result, .. } => Ok(result),
116            other => Self::fold_unexpected(other),
117        }
118    }
119
120    /// Run the proxy's aggregated `tools/call` for `response_id` over
121    /// this WS (routes by tool-name prefix to the owning upstream;
122    /// does NOT consult the queue delegate). See [`Self::list_tools`]
123    /// for the error-folding contract.
124    pub async fn call_tool(
125        &self,
126        response_id: String,
127        params: crate::mcp::tool::CallToolRequestParams,
128    ) -> Result<
129        server_response::JsonRpcResult<crate::mcp::tool::CallToolResult>,
130        super::HttpError,
131    > {
132        match self
133            .send_raw(client_request::Payload::CallTool {
134                response_id,
135                params,
136            })
137            .await?
138        {
139            client_response::Response::CallTool { result, .. } => Ok(result),
140            other => Self::fold_unexpected(other),
141        }
142    }
143
144    /// Run the proxy's aggregated `resources/list` for `response_id`
145    /// over this WS. See [`Self::list_tools`] for the error contract.
146    pub async fn list_resources(
147        &self,
148        response_id: String,
149        params: crate::mcp::resource::ListResourcesRequest,
150    ) -> Result<
151        server_response::JsonRpcResult<crate::mcp::resource::ListResourcesResult>,
152        super::HttpError,
153    > {
154        match self
155            .send_raw(client_request::Payload::ListResources {
156                response_id,
157                params,
158            })
159            .await?
160        {
161            client_response::Response::ListResources { result, .. } => {
162                Ok(result)
163            }
164            other => Self::fold_unexpected(other),
165        }
166    }
167
168    /// Run the proxy's `resources/read` for `response_id` over this WS
169    /// (routes by URI prefix). See [`Self::list_tools`] for the error
170    /// contract.
171    pub async fn read_resource(
172        &self,
173        response_id: String,
174        params: crate::mcp::resource::ReadResourceRequestParams,
175    ) -> Result<
176        server_response::JsonRpcResult<crate::mcp::resource::ReadResourceResult>,
177        super::HttpError,
178    > {
179        match self
180            .send_raw(client_request::Payload::ReadResource {
181                response_id,
182                params,
183            })
184            .await?
185        {
186            client_response::Response::ReadResource { result, .. } => {
187                Ok(result)
188            }
189            other => Self::fold_unexpected(other),
190        }
191    }
192
193    /// Map a non-matching reply for an MCP-op request into the
194    /// `JsonRpcResult<R>` the caller expects:
195    ///
196    /// - generic `Error { code, message }` — a server-level rejection
197    ///   (e.g. unknown / banned `response_id`) — folds into
198    ///   `JsonRpcResult::Err` so every server-originated outcome is one
199    ///   MCP-shaped value.
200    /// - any other variant is a correlation/protocol bug (a reply for a
201    ///   different request kind reached this oneshot) → `NotifyRejected`.
202    fn fold_unexpected<R>(
203        other: client_response::Response,
204    ) -> Result<server_response::JsonRpcResult<R>, super::HttpError> {
205        match other {
206            client_response::Response::Error { code, message, .. } => {
207                let message = match message {
208                    serde_json::Value::String(s) => s,
209                    other => other.to_string(),
210                };
211                Ok(server_response::JsonRpcResult::Err {
212                    code: code as i64,
213                    message,
214                    data: None,
215                })
216            }
217            _ => Err(super::HttpError::NotifyRejected {
218                code: 0,
219                message: serde_json::Value::String(
220                    "unexpected reply variant for mcp request".to_string(),
221                ),
222            }),
223        }
224    }
225
226    /// Common send-and-await body: assign a correlation id, park a
227    /// oneshot in `pending`, write the framed request, and return the
228    /// raw [`client_response::Response`] the demux task routes back by
229    /// id. Callers interpret the variant.
230    async fn send_raw(
231        &self,
232        payload: client_request::Payload,
233    ) -> Result<client_response::Response, super::HttpError> {
234        let id = uuid::Uuid::new_v4().to_string();
235        let (tx, rx) = oneshot::channel();
236        self.pending.insert(id.clone(), tx);
237
238        let request = client_request::Request {
239            id: id.clone(),
240            payload,
241        };
242        let frame = match serde_json::to_string(&request) {
243            Ok(s) => s,
244            Err(e) => {
245                self.pending.remove(&id);
246                return Err(super::HttpError::NotifySerialize(e));
247            }
248        };
249
250        {
251            let mut guard = self.sink.lock().await;
252            if let Err(e) =
253                guard.send(tungstenite::Message::Text(frame.into())).await
254            {
255                drop(guard);
256                self.pending.remove(&id);
257                return Err(super::HttpError::NotifySend(e));
258            }
259        }
260
261        match rx.await {
262            Ok(r) => Ok(r),
263            Err(_) => Err(super::HttpError::NotifyChannelClosed),
264        }
265    }
266}