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}