Skip to main content

objectiveai_mcp_proxy/
reverse_channel.rs

1//! WS reverse-channel transport for CLI-hosted upstreams.
2//!
3//! When the proxy is embedded in the API (per request), it is handed a
4//! [`ReverseChannel`] — the means to speak the `client_objectiveai_mcp`
5//! protocol over the request's WebSocket. Upstreams whose URL scheme is
6//! `ws` ([`WsUpstream`]) are reached through it instead of over HTTP:
7//!
8//! - `ws://objectiveai` → [`McpKind::ObjectiveAi`]
9//! - `ws:///owner/name/version/mcp` → [`McpKind::Plugin`]
10//!
11//! Direction split (the API owns the WS itself):
12//! - **send**: the proxy emits a `server_request::Request` into the
13//!   channel's mpsc; the API serializes it onto the shared WS sink.
14//! - **recv**: the API's recv loop demuxes incoming frames by type and
15//!   hands the proxy-bound ones back via [`ReverseChannel::deliver_response`]
16//!   (the 6 MCP `server_response` variants) and
17//!   [`ReverseChannel::deliver_client_request`] (`McpListChanged`). The
18//!   proxy correlates responses to its own outstanding requests by id.
19//!
20//! [`Upstream`] is the proxy's per-upstream handle — either an HTTP
21//! [`Connection`] or a [`WsUpstream`] — exposing the slice of the
22//! `Connection` interface the [`crate::session::Session`] depends on.
23
24use std::sync::{Arc, OnceLock};
25use std::time::Duration;
26
27use dashmap::DashMap;
28use indexmap::IndexMap;
29use objectiveai_sdk::client_objectiveai_mcp::{
30    McpKind,
31    client_request::{self, McpListChangedKind},
32    client_response,
33    server_request::{self, InitializeRequest, Request as ServerRequest},
34    server_response::{self, JsonRpcResult, Response as ServerResponse},
35};
36use objectiveai_sdk::mcp::resource::{
37    ListResourcesRequest, ReadResourceRequestParams, ReadResourceResult, Resource,
38};
39use objectiveai_sdk::mcp::tool::{
40    CallToolRequestParams, CallToolResult, ListToolsRequest, Tool,
41};
42use objectiveai_sdk::mcp::{Connection, Error as McpError};
43use tokio::sync::{RwLock, mpsc, oneshot};
44
45use crate::session::Session;
46use crate::session_manager::SessionManager;
47
48/// A list-changed callback (mirrors `Connection::set_on_*_list_changed`).
49type ListChangedCb = Arc<dyn Fn() + Send + Sync>;
50
51struct Inner {
52    /// proxy → API → WS. The API drains the paired receiver and writes
53    /// each request onto the shared WS sink.
54    tx: mpsc::UnboundedSender<ServerRequest>,
55    /// Outstanding requests awaiting their `server_response`, by id.
56    pending: DashMap<String, oneshot::Sender<ServerResponse>>,
57    /// Per-upstream round-trip budget.
58    timeout: Duration,
59    /// list-changed callbacks per upstream `McpKind`: `(tools, resources)`.
60    /// Fired when a matching `client_request::McpListChanged` arrives.
61    list_changed: DashMap<McpKind, (Option<ListChangedCb>, Option<ListChangedCb>)>,
62    /// Session registry, late-bound by [`ReverseChannel::wire_sessions`]
63    /// in `setup` (the channel is built before the proxy's
64    /// `SessionManager` exists). Lets inbound `client_request`s
65    /// (`ListTools`/`CallTool`/`ListResources`/`ReadResource`) run the
66    /// proxy's aggregated MCP ops by `response_id`.
67    sessions: OnceLock<Arc<SessionManager>>,
68}
69
70/// Cheaply-cloneable handle the proxy uses to speak over the WS.
71#[derive(Clone)]
72pub struct ReverseChannel(Arc<Inner>);
73
74impl std::fmt::Debug for ReverseChannel {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("ReverseChannel").finish_non_exhaustive()
77    }
78}
79
80impl ReverseChannel {
81    /// Build a channel. Returns the channel plus the receiver the API
82    /// drains (serializing each `server_request` onto the shared WS sink).
83    pub fn new(timeout: Duration) -> (Self, mpsc::UnboundedReceiver<ServerRequest>) {
84        let (tx, rx) = mpsc::unbounded_channel();
85        let inner = Inner {
86            tx,
87            pending: DashMap::new(),
88            timeout,
89            list_changed: DashMap::new(),
90            sessions: OnceLock::new(),
91        };
92        (Self(Arc::new(inner)), rx)
93    }
94
95    /// Late-bind the proxy's session registry so inbound MCP-op
96    /// `client_request`s can resolve a session by `response_id`. Called
97    /// once by `setup` (idempotent — first write wins).
98    pub(crate) fn wire_sessions(&self, sessions: Arc<SessionManager>) {
99        let _ = self.0.sessions.set(sessions);
100    }
101
102    /// Resolve a session for an inbound MCP-op `client_request`. Returns
103    /// a `(code, message)` error suitable for a `JsonRpcResult::Err` when
104    /// sessions aren't wired or no session exists for `response_id`.
105    /// Resolve a session for a client-request MCP op. A response id
106    /// whose initial connect is in flight parks here until the connect
107    /// finishes (see `SessionManager::get_or_wait`) instead of failing
108    /// with `-32001` — an upstream server may call back in while it is
109    /// itself being connected.
110    async fn lookup_session(
111        &self,
112        response_id: &str,
113    ) -> Result<Arc<Session>, (i64, String)> {
114        let sessions = self
115            .0
116            .sessions
117            .get()
118            .ok_or((-32603i64, "proxy sessions not wired".to_string()))?;
119        sessions
120            .get_or_wait(response_id)
121            .await
122            .ok_or_else(|| (-32001i64, format!("unknown session for response id {response_id:?}")))
123    }
124
125    /// Emit a `server_request` and await its matching `server_response`,
126    /// bounded by the configured timeout. `id` is minted here; the API's
127    /// recv loop routes the reply back via [`Self::deliver_response`].
128    async fn request(
129        &self,
130        payload: server_request::Payload,
131        headers: IndexMap<String, String>,
132    ) -> Result<ServerResponse, McpError> {
133        let id = uuid::Uuid::new_v4().to_string();
134        let (resp_tx, resp_rx) = oneshot::channel();
135        self.0.pending.insert(id.clone(), resp_tx);
136        let request = ServerRequest {
137            id: id.clone(),
138            headers,
139            payload,
140        };
141        if self.0.tx.send(request).is_err() {
142            self.0.pending.remove(&id);
143            return Err(transport_error("reverse channel closed before send"));
144        }
145        match tokio::time::timeout(self.0.timeout, resp_rx).await {
146            Ok(Ok(response)) => Ok(response),
147            Ok(Err(_)) => {
148                self.0.pending.remove(&id);
149                Err(transport_error("reverse channel dropped before response"))
150            }
151            Err(_) => {
152                self.0.pending.remove(&id);
153                Err(transport_error("reverse channel timed out waiting for response"))
154            }
155        }
156    }
157
158    /// Best-effort `Drop` server-request for `response_id`: tells the CLI
159    /// to tear down the whole response-id bucket (connections + plugin
160    /// subprocesses). The reply (`DropResult`) is discarded; transport
161    /// errors / timeouts are ignored — teardown is fire-and-forget.
162    pub(crate) async fn drop_response(&self, response_id: String) {
163        let _ = self
164            .request(
165                server_request::Payload::Drop(server_request::DropRequest { response_id }),
166                IndexMap::new(),
167            )
168            .await;
169    }
170
171    /// Stream a file/folder from one laboratory to another. The conduit
172    /// splices the source laboratory's `/export` straight into the
173    /// destination's `/import`; both laboratories are on the same conduit,
174    /// so this rides the session's reverse channel like any other op.
175    pub async fn transfer_laboratories(
176        &self,
177        source_id: String,
178        dest_id: String,
179        source_path: String,
180        dest_path: String,
181    ) -> Result<server_response::LaboratoryTransferResult, McpError> {
182        let response = self
183            .request(
184                server_request::Payload::LaboratoryTransfer(
185                    server_request::LaboratoryTransferRequest {
186                        source_id,
187                        dest_id,
188                        source_path,
189                        dest_path,
190                    },
191                ),
192                IndexMap::new(),
193            )
194            .await?;
195        match response.payload {
196            server_response::Payload::LaboratoryTransfer(result) => {
197                unwrap_rpc("laboratory_transfer", result)
198            }
199            other => Err(variant_mismatch(
200                "laboratory_transfer",
201                "laboratory_transfer",
202                &other,
203            )),
204        }
205    }
206
207    /// Hand a proxy-bound `server_response` (one of the 6 MCP variants)
208    /// back to the waiter that issued the matching request. Called by the
209    /// API's recv loop. Unknown id → dropped.
210    pub fn deliver_response(&self, response: ServerResponse) {
211        if let Some((_, tx)) = self.0.pending.remove(&response.id) {
212            let _ = tx.send(response);
213        }
214    }
215
216    /// Hand a proxy-bound `client_request` to the proxy.
217    ///
218    /// `McpListChanged` fires the registered list-changed callback for the
219    /// upstream. The MCP-op variants (`ListTools`/`CallTool`/
220    /// `ListResources`/`ReadResource`) resolve the session by
221    /// `response_id` and run the SAME shared [`crate::session::Session`]
222    /// code the HTTP endpoints use — fanning out / routing exactly as
223    /// `mcp::handle_tools_list` etc. — returning the normal MCP result.
224    /// NOTE: the `CallTool` path deliberately does NOT consult the queue
225    /// delegate (that splice is only for the regular HTTP `tools/call`).
226    /// Returns the ack/result the API writes back over the WS.
227    pub async fn deliver_client_request(
228        &self,
229        request: client_request::Request,
230    ) -> client_response::Response {
231        let client_request::Request { id, payload } = request;
232        match payload {
233            client_request::Payload::McpListChanged(change) => {
234                if let Some(cbs) = self.0.list_changed.get(&change.mcp_kind) {
235                    let cb = match change.kind {
236                        McpListChangedKind::Tools => cbs.0.clone(),
237                        McpListChangedKind::Resources => cbs.1.clone(),
238                    };
239                    drop(cbs);
240                    if let Some(cb) = cb {
241                        cb();
242                    }
243                }
244                client_response::Response::Ok { id }
245            }
246            // List params (cursor) are ignored, matching the HTTP
247            // `handle_tools_list` which fans out to every upstream.
248            // List params (cursor) are ignored, matching the HTTP
249            // `handle_tools_list`; `name`, when set, scopes the fan-out to
250            // the single server with that routing prefix.
251            client_request::Payload::ListTools {
252                response_id, name, ..
253            } => {
254                let result = match self.lookup_session(&response_id).await {
255                    Ok(session) => {
256                        match session.list_tools_filtered(None, name.as_deref()).await {
257                            Ok(result) => JsonRpcResult::Ok { result },
258                            Err(e) => rpc_err_result(-32603, format!("list_tools: {e}")),
259                        }
260                    }
261                    Err((code, message)) => rpc_err_result(code, message),
262                };
263                client_response::Response::ListTools { id, result }
264            }
265            client_request::Payload::ListResources {
266                response_id, name, ..
267            } => {
268                let result = match self.lookup_session(&response_id).await {
269                    Ok(session) => {
270                        match session.list_resources_filtered(None, name.as_deref()).await {
271                            Ok(result) => JsonRpcResult::Ok { result },
272                            Err(e) => rpc_err_result(-32603, format!("list_resources: {e}")),
273                        }
274                    }
275                    Err((code, message)) => rpc_err_result(code, message),
276                };
277                client_response::Response::ListResources { id, result }
278            }
279            client_request::Payload::ListServers { response_id } => {
280                let result = match self.lookup_session(&response_id).await {
281                    // Proxy-local aggregate — no upstream fan-out, can't fail.
282                    Ok(session) => JsonRpcResult::Ok {
283                        result: session.list_servers(),
284                    },
285                    Err((code, message)) => rpc_err_result(code, message),
286                };
287                client_response::Response::ListServers { id, result }
288            }
289            client_request::Payload::CallTool { response_id, params } => {
290                let result = match self.lookup_session(&response_id).await {
291                    // No queue delegate here — unlike the HTTP path, this
292                    // returns the upstream tool result verbatim.
293                    Ok(session) => match session.call_tool(&params).await {
294                        Ok(result) => JsonRpcResult::Ok { result },
295                        Err(crate::session::CallToolError::ToolNotFound(name)) => {
296                            rpc_err_result(-32601, format!("tool not found: {name}"))
297                        }
298                        Err(crate::session::CallToolError::Upstream(e)) => {
299                            rpc_err_result(-32603, format!("upstream call_tool: {e}"))
300                        }
301                    },
302                    Err((code, message)) => rpc_err_result(code, message),
303                };
304                client_response::Response::CallTool { id, result }
305            }
306            client_request::Payload::ReadResource { response_id, params } => {
307                let result = match self.lookup_session(&response_id).await {
308                    Ok(session) => match session.read_resource(&params.uri).await {
309                        Ok(result) => JsonRpcResult::Ok { result },
310                        Err(crate::session::ReadResourceError::ResourceNotFound(uri)) => {
311                            rpc_err_result(-32602, format!("resource not found: {uri}"))
312                        }
313                        Err(crate::session::ReadResourceError::Upstream(e)) => {
314                            rpc_err_result(-32603, format!("upstream read_resource: {e}"))
315                        }
316                    },
317                    Err((code, message)) => rpc_err_result(code, message),
318                };
319                client_response::Response::ReadResource { id, result }
320            }
321        }
322    }
323
324    fn set_tools_list_changed(&self, mcp_kind: McpKind, cb: ListChangedCb) {
325        let mut entry = self.0.list_changed.entry(mcp_kind).or_default();
326        entry.0 = Some(cb);
327    }
328
329    fn set_resources_list_changed(&self, mcp_kind: McpKind, cb: ListChangedCb) {
330        let mut entry = self.0.list_changed.entry(mcp_kind).or_default();
331        entry.1 = Some(cb);
332    }
333}
334
335/// A `ws://`-scheme upstream, reached over the [`ReverseChannel`]. Mirrors
336/// the slice of [`Connection`]'s interface the [`crate::session::Session`]
337/// uses, translating each op into a `server_request` carrying this
338/// upstream's [`McpKind`].
339pub struct WsUpstream {
340    channel: ReverseChannel,
341    mcp_kind: McpKind,
342    /// The `ws://…` URL this upstream was dialed with (used for filtering).
343    pub url: String,
344    /// Upstream `Mcp-Session-Id` returned by the CLI on `initialize`.
345    pub session_id: String,
346    /// Upstream `server_info.name` / `.version` from the `initialize`
347    /// reply — feeds the session's routing-prefix derivation.
348    server_name: String,
349    server_version: String,
350    /// The upstream's full `initialize` reply (capabilities, server_info,
351    /// instructions, protocol version) — kept verbatim so `servers/list`
352    /// can report it. `Connection` exposes the same via its own
353    /// `initialize_result`.
354    initialize_result: objectiveai_sdk::mcp::initialize_result::InitializeResult,
355    /// Typed laboratory identity from the explicit `X-MCP-Laboratories`
356    /// marker — the authoritative "this upstream is a laboratory" signal,
357    /// `None` for non-laboratory upstreams. Never derived from the URL.
358    laboratory: Option<objectiveai_sdk::laboratories::Laboratory>,
359    /// Whether the upstream advertised the `tools` / `resources`
360    /// capability in its `initialize` reply. We must NOT issue
361    /// `tools/list` / `resources/list` against an upstream that didn't
362    /// advertise the capability: many servers (incl. the test
363    /// fixtures) 404 the un-advertised method, and a hard error there
364    /// fails the whole aggregate — and, on the post-init health probe,
365    /// fails the connect and churns endless re-`initialize`s. Mirrors
366    /// `mcp::Connection::has_{tools,resources}_cap`.
367    has_tools_cap: bool,
368    has_resources_cap: bool,
369    /// Persistent per-upstream headers captured at connect: the per-URL
370    /// set (`Authorization`, custom `X-*`, `X-OBJECTIVEAI-ARGUMENTS`)
371    /// plus whatever identity headers were present at dial. Never
372    /// mutated after connect — mirrors the SDK `Connection`'s base
373    /// `headers`. The transient subset is overridden per request by
374    /// `extra_headers`; the per-URL subset has no overlay key, so it
375    /// always survives on every request.
376    base_headers: IndexMap<String, String>,
377    /// Mutable transient-identity overlay, full-replaced every turn by
378    /// `apply_transient_headers` → `set_extra_headers`. Overrides
379    /// `base_headers` per key (mirrors `Connection::extra_headers`).
380    /// Starts empty: until the first refresh, `base_headers` alone
381    /// carries the dial-time identity headers.
382    extra_headers: RwLock<IndexMap<String, String>>,
383}
384
385impl std::fmt::Debug for WsUpstream {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.debug_struct("WsUpstream")
388            .field("url", &self.url)
389            .field("session_id", &self.session_id)
390            .finish_non_exhaustive()
391    }
392}
393
394impl WsUpstream {
395    /// Headers to stamp on every outbound `server_request`, mirroring
396    /// `Connection::build_request_headers` exactly: the persistent
397    /// `base_headers` first, then the `extra_headers` transient overlay
398    /// (overrides per key), then this upstream's `Mcp-Session-Id` last
399    /// (so it can never be shadowed). Per-URL headers live only in
400    /// `base_headers` (no overlay key collides), so they're present on
401    /// EVERY request — identical to the HTTP path.
402    async fn headers(&self) -> IndexMap<String, String> {
403        let mut h = self.base_headers.clone();
404        for (k, v) in self.extra_headers.read().await.iter() {
405            h.insert(k.clone(), v.clone());
406        }
407        h.insert(
408            crate::upstream::MCP_SESSION_ID_KEY.to_string(),
409            self.session_id.clone(),
410        );
411        h
412    }
413
414    pub async fn list_tools(&self) -> Result<Arc<Vec<Tool>>, Arc<McpError>> {
415        // Capability gate — an upstream that didn't advertise `tools`
416        // has no `tools/list`; calling it anyway 404s on most servers.
417        if !self.has_tools_cap {
418            return Ok(Arc::new(Vec::new()));
419        }
420        let headers = self.headers().await;
421        let response = self
422            .channel
423            .request(
424                server_request::Payload::ToolsList {
425                    mcp_kind: self.mcp_kind.clone(),
426                    params: ListToolsRequest { cursor: None },
427                },
428                headers,
429            )
430            .await
431            .map_err(Arc::new)?;
432        match response.payload {
433            server_response::Payload::ToolsList { result, .. } => {
434                Ok(Arc::new(unwrap_rpc(&self.url, result).map_err(Arc::new)?.tools))
435            }
436            other => Err(Arc::new(variant_mismatch(&self.url, "tools_list", &other))),
437        }
438    }
439
440    pub async fn list_resources(&self) -> Result<Arc<Vec<Resource>>, Arc<McpError>> {
441        // Capability gate — an upstream that didn't advertise
442        // `resources` has no `resources/list`; calling it anyway 404s
443        // on most servers (e.g. the tools-only plugin fixtures).
444        if !self.has_resources_cap {
445            return Ok(Arc::new(Vec::new()));
446        }
447        let headers = self.headers().await;
448        let response = self
449            .channel
450            .request(
451                server_request::Payload::ResourcesList {
452                    mcp_kind: self.mcp_kind.clone(),
453                    params: ListResourcesRequest { cursor: None },
454                },
455                headers,
456            )
457            .await
458            .map_err(Arc::new)?;
459        match response.payload {
460            server_response::Payload::ResourcesList { result, .. } => {
461                Ok(Arc::new(unwrap_rpc(&self.url, result).map_err(Arc::new)?.resources))
462            }
463            other => Err(Arc::new(variant_mismatch(&self.url, "resources_list", &other))),
464        }
465    }
466
467    pub async fn call_tool(
468        &self,
469        params: &CallToolRequestParams,
470    ) -> Result<CallToolResult, McpError> {
471        let headers = self.headers().await;
472        let response = self
473            .channel
474            .request(
475                server_request::Payload::ToolsCall {
476                    mcp_kind: self.mcp_kind.clone(),
477                    params: params.clone(),
478                },
479                headers,
480            )
481            .await?;
482        match response.payload {
483            server_response::Payload::ToolsCall { result, .. } => unwrap_rpc(&self.url, result),
484            other => Err(variant_mismatch(&self.url, "tools_call", &other)),
485        }
486    }
487
488    pub async fn read_resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
489        let headers = self.headers().await;
490        let response = self
491            .channel
492            .request(
493                server_request::Payload::ResourcesRead {
494                    mcp_kind: self.mcp_kind.clone(),
495                    params: ReadResourceRequestParams {
496                        uri: uri.to_string(),
497                    },
498                },
499                headers,
500            )
501            .await?;
502        match response.payload {
503            server_response::Payload::ResourcesRead { result, .. } => unwrap_rpc(&self.url, result),
504            other => Err(variant_mismatch(&self.url, "resources_read", &other)),
505        }
506    }
507
508    pub async fn delete(&self) -> Result<(), McpError> {
509        let headers = self.headers().await;
510        let response = self
511            .channel
512            .request(
513                server_request::Payload::SessionTerminate {
514                    mcp_kind: self.mcp_kind.clone(),
515                },
516                headers,
517            )
518            .await?;
519        match response.payload {
520            server_response::Payload::SessionTerminate { result, .. } => unwrap_rpc(&self.url, result),
521            other => Err(variant_mismatch(&self.url, "session_terminate", &other)),
522        }
523    }
524
525    pub fn set_on_tools_list_changed<F>(&self, callback: F)
526    where
527        F: Fn() + Send + Sync + 'static,
528    {
529        self.channel
530            .set_tools_list_changed(self.mcp_kind.clone(), Arc::new(callback));
531    }
532
533    pub fn set_on_resources_list_changed<F>(&self, callback: F)
534    where
535        F: Fn() + Send + Sync + 'static,
536    {
537        self.channel
538            .set_resources_list_changed(self.mcp_kind.clone(), Arc::new(callback));
539    }
540
541    pub async fn set_extra_headers(&self, extras: IndexMap<String, String>) {
542        *self.extra_headers.write().await = extras;
543    }
544}
545
546/// A per-upstream handle: HTTP [`Connection`] or WS [`WsUpstream`]. Exposes
547/// exactly the surface [`crate::session::Session`] + `handle_delete` use.
548#[derive(Debug)]
549pub enum Upstream {
550    Http(Connection),
551    Ws(WsUpstream),
552}
553
554impl Upstream {
555    /// Whether this upstream is reached over the `client_objectiveai_mcp`
556    /// reverse channel (a `ws://` upstream) rather than plain HTTP.
557    pub fn is_ws(&self) -> bool {
558        matches!(self, Upstream::Ws(_))
559    }
560
561    pub fn url(&self) -> &str {
562        match self {
563            Upstream::Http(c) => &c.url,
564            Upstream::Ws(w) => &w.url,
565        }
566    }
567
568    pub fn session_id(&self) -> &str {
569        match self {
570            Upstream::Http(c) => &c.session_id,
571            Upstream::Ws(w) => &w.session_id,
572        }
573    }
574
575    /// Upstream `server_info.name` — used to derive the session's routing
576    /// prefix. (`Connection` exposes it via `initialize_result`.)
577    pub fn server_name(&self) -> &str {
578        match self {
579            Upstream::Http(c) => &c.initialize_result.server_info.name,
580            Upstream::Ws(w) => &w.server_name,
581        }
582    }
583
584    /// Upstream `server_info.version` — the prefix collision tie-breaker.
585    pub fn server_version(&self) -> &str {
586        match self {
587            Upstream::Http(c) => &c.initialize_result.server_info.version,
588            Upstream::Ws(w) => &w.server_version,
589        }
590    }
591
592    /// The upstream's full `initialize` reply (capabilities, server_info,
593    /// instructions, protocol version) — used by `servers/list`.
594    pub fn initialize_result(
595        &self,
596    ) -> &objectiveai_sdk::mcp::initialize_result::InitializeResult {
597        match self {
598            Upstream::Http(c) => &c.initialize_result,
599            Upstream::Ws(w) => &w.initialize_result,
600        }
601    }
602
603    /// The laboratory this upstream IS, if any — for `servers/list` and
604    /// `laboratory_transfer`.
605    ///
606    /// Read from the explicit, typed laboratory marker the API supplied
607    /// (`X-MCP-Laboratories`), NOT inferred by string-parsing the
608    /// `ws://laboratory/{id}` URL. HTTP upstreams and unmarked websocket
609    /// upstreams (the primary `objectiveai` MCP, plugins) are `None`.
610    pub fn laboratory(&self) -> Option<objectiveai_sdk::laboratories::Laboratory> {
611        match self {
612            Upstream::Http(_) => None,
613            Upstream::Ws(w) => w.laboratory.clone(),
614        }
615    }
616
617    /// The plugin this upstream IS, if any — for `servers/list`. Same
618    /// transport+kind gating as [`Self::laboratory`]: only a websocket
619    /// upstream whose `McpKind` is `Plugin` maps to a [`Plugin`]; HTTP and
620    /// non-plugin websocket kinds are `None`.
621    pub fn plugin(&self) -> Option<objectiveai_sdk::mcp::server::Plugin> {
622        match self {
623            Upstream::Http(_) => None,
624            Upstream::Ws(w) => match &w.mcp_kind {
625                McpKind::Plugin {
626                    owner,
627                    name,
628                    version,
629                    mcp,
630                } => Some(objectiveai_sdk::mcp::server::Plugin {
631                    owner: owner.clone(),
632                    name: name.clone(),
633                    version: version.clone(),
634                    mcp: mcp.clone(),
635                }),
636                McpKind::ObjectiveAi | McpKind::Laboratory { .. } => None,
637            },
638        }
639    }
640
641    /// The session reverse channel this upstream rides, for proxy-level
642    /// ops that aren't a per-upstream MCP call (e.g. laboratory transfer,
643    /// which spans two laboratories on the same conduit). `None` for HTTP
644    /// upstreams, which have no reverse channel.
645    pub fn reverse_channel(&self) -> Option<&ReverseChannel> {
646        match self {
647            Upstream::Http(_) => None,
648            Upstream::Ws(w) => Some(&w.channel),
649        }
650    }
651
652    pub async fn list_tools(&self) -> Result<Arc<Vec<Tool>>, Arc<McpError>> {
653        match self {
654            Upstream::Http(c) => c.list_tools().await,
655            Upstream::Ws(w) => w.list_tools().await,
656        }
657    }
658
659    pub async fn list_resources(&self) -> Result<Arc<Vec<Resource>>, Arc<McpError>> {
660        match self {
661            Upstream::Http(c) => c.list_resources().await,
662            Upstream::Ws(w) => w.list_resources().await,
663        }
664    }
665
666    pub async fn call_tool(
667        &self,
668        params: &CallToolRequestParams,
669    ) -> Result<CallToolResult, McpError> {
670        match self {
671            Upstream::Http(c) => c.call_tool(params).await,
672            Upstream::Ws(w) => w.call_tool(params).await,
673        }
674    }
675
676    pub async fn read_resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
677        match self {
678            Upstream::Http(c) => c.read_resource(uri).await,
679            Upstream::Ws(w) => w.read_resource(uri).await,
680        }
681    }
682
683    pub async fn delete(&self) -> Result<(), McpError> {
684        match self {
685            Upstream::Http(c) => c.delete().await,
686            Upstream::Ws(w) => w.delete().await,
687        }
688    }
689
690    pub fn set_on_tools_list_changed<F>(&self, callback: F)
691    where
692        F: Fn() + Send + Sync + 'static,
693    {
694        match self {
695            Upstream::Http(c) => c.set_on_tools_list_changed(callback),
696            Upstream::Ws(w) => w.set_on_tools_list_changed(callback),
697        }
698    }
699
700    pub fn set_on_resources_list_changed<F>(&self, callback: F)
701    where
702        F: Fn() + Send + Sync + 'static,
703    {
704        match self {
705            Upstream::Http(c) => c.set_on_resources_list_changed(callback),
706            Upstream::Ws(w) => w.set_on_resources_list_changed(callback),
707        }
708    }
709
710    pub async fn set_extra_headers(&self, extras: IndexMap<String, String>) {
711        match self {
712            Upstream::Http(c) => c.set_extra_headers(extras).await,
713            Upstream::Ws(w) => w.set_extra_headers(extras).await,
714        }
715    }
716}
717
718/// Parse a `ws://objectiveai` / `ws:///owner/name/version/mcp` URL into
719/// its [`McpKind`]. Returns `None` for any other shape.
720///
721/// NOTE: laboratories are deliberately NOT parsed here. A laboratory's
722/// `McpKind` comes from the explicit, typed `X-MCP-Laboratories` marker
723/// (see `crate::upstream`), never from string-matching the URL — so the
724/// proxy's notion of "this is a laboratory" has a single authoritative
725/// source.
726pub fn parse_ws_mcp_kind(url: &str) -> Option<McpKind> {
727    let rest = url.strip_prefix("ws://")?;
728    // Drop any `?query` (plugin args ride there, parsed separately).
729    let rest = rest.split('?').next().unwrap_or(rest);
730    // `ws://objectiveai` → host "objectiveai", no path.
731    if rest == "objectiveai" {
732        return Some(McpKind::ObjectiveAi);
733    }
734    // `ws:///owner/name/version/mcp` → empty host, leading '/'.
735    let path = rest.strip_prefix('/')?;
736    let parts: Vec<&str> = path.split('/').collect();
737    if let [owner, name, version, mcp] = parts.as_slice() {
738        if !owner.is_empty() && !name.is_empty() && !version.is_empty() && !mcp.is_empty() {
739            return Some(McpKind::Plugin {
740                owner: (*owner).to_string(),
741                name: (*name).to_string(),
742                version: (*version).to_string(),
743                mcp: (*mcp).to_string(),
744            });
745        }
746    }
747    None
748}
749
750/// `initialize` a `ws://` upstream over `channel` and build its
751/// [`WsUpstream`]. `headers` is the full set sent on the `initialize`
752/// request — the session-global transient identity headers, plus (on
753/// resume) the upstream `Mcp-Session-Id` and any auth. `args` carries
754/// plugin init arguments (empty for `objectiveai`).
755pub async fn connect_ws(
756    channel: ReverseChannel,
757    url: String,
758    mcp_kind: McpKind,
759    args: IndexMap<String, Option<String>>,
760    mut headers: IndexMap<String, String>,
761    laboratory: Option<objectiveai_sdk::laboratories::Laboratory>,
762) -> Result<WsUpstream, McpError> {
763    let response = channel
764        .request(
765            server_request::Payload::Initialize {
766                mcp_kind: mcp_kind.clone(),
767                params: InitializeRequest { args },
768            },
769            headers.clone(),
770        )
771        .await?;
772    let reply = match response.payload {
773        server_response::Payload::Initialize { result, .. } => unwrap_rpc(&url, result)?,
774        other => return Err(variant_mismatch(&url, "initialize", &other)),
775    };
776    // The per-request stamped set drops the resume `Mcp-Session-Id`
777    // ([`WsUpstream::headers`] re-adds whatever the upstream just minted)
778    // but keeps the transient identity + auth so the post-init health
779    // probe + every later call still pass the conduit's transient check.
780    headers.shift_remove(crate::upstream::MCP_SESSION_ID_KEY);
781    let session_id = reply.mcp_session_id;
782    let initialize_result = reply.result;
783    let has_tools_cap = initialize_result.capabilities.tools.is_some();
784    let has_resources_cap = initialize_result.capabilities.resources.is_some();
785    let server_name = initialize_result.server_info.name.clone();
786    let server_version = initialize_result.server_info.version.clone();
787    Ok(WsUpstream {
788        channel,
789        mcp_kind,
790        url,
791        session_id,
792        server_name,
793        server_version,
794        initialize_result,
795        laboratory,
796        has_tools_cap,
797        has_resources_cap,
798        // The connect-time set (per-URL ∪ dial-time identity) is the
799        // persistent base; the transient overlay starts empty and is
800        // filled by the first `set_extra_headers`. Mirrors the SDK
801        // `Connection`, where connect headers are the base and
802        // `extra_headers` begins empty.
803        base_headers: headers,
804        extra_headers: RwLock::new(IndexMap::new()),
805    })
806}
807
808fn unwrap_rpc<R>(url: &str, result: JsonRpcResult<R>) -> Result<R, McpError> {
809    match result {
810        JsonRpcResult::Ok { result } => Ok(result),
811        JsonRpcResult::Err {
812            code,
813            message,
814            data,
815        } => Err(McpError::JsonRpc {
816            url: url.to_string(),
817            code,
818            message,
819            data,
820        }),
821    }
822}
823
824fn transport_error(message: &str) -> McpError {
825    McpError::MalformedResponse {
826        url: "ws".to_string(),
827        message: message.to_string(),
828    }
829}
830
831/// Build a `JsonRpcResult::Err` for an inbound MCP-op `client_request`
832/// (`deliver_client_request`). Generic over the result type so each
833/// op's reply variant infers `R`.
834fn rpc_err_result<R>(code: i64, message: String) -> JsonRpcResult<R> {
835    JsonRpcResult::Err {
836        code,
837        message,
838        data: None,
839    }
840}
841
842fn variant_mismatch(url: &str, expected: &str, got: &server_response::Payload) -> McpError {
843    McpError::MalformedResponse {
844        url: url.to_string(),
845        message: format!(
846            "reverse channel returned wrong payload variant: expected {expected}, got {}",
847            got_variant_name(got),
848        ),
849    }
850}
851
852fn got_variant_name(p: &server_response::Payload) -> &'static str {
853    use server_response::Payload as P;
854    match p {
855        P::Initialize { .. } => "initialize",
856        P::ToolsList { .. } => "tools_list",
857        P::ToolsCall { .. } => "tools_call",
858        P::ResourcesList { .. } => "resources_list",
859        P::ResourcesRead { .. } => "resources_read",
860        P::SessionTerminate { .. } => "session_terminate",
861        P::ReadMessageQueue(_) => "read_message_queue",
862        P::Retrieve(_) => "retrieve",
863        P::Drop(_) => "drop",
864        P::LaboratoryTransfer(_) => "laboratory_transfer",
865    }
866}