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}