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    /// There is NO channel-level round-trip budget: each op passes its
57    /// own `Option<Duration>` to [`ReverseChannel::request`] — ws-MCP
58    /// calls use the per-request `X-MCP-CALL-TIMEOUT` value, connects
59    /// use the connect timeout, and laboratory transfers + drops run
60    /// timeout-free.
61    pending: DashMap<String, oneshot::Sender<ServerResponse>>,
62    /// list-changed callbacks per upstream `McpKind`: `(tools, resources)`.
63    /// Fired when a matching `client_request::McpListChanged` arrives.
64    list_changed: DashMap<McpKind, (Option<ListChangedCb>, Option<ListChangedCb>)>,
65    /// Session registry, late-bound by [`ReverseChannel::wire_sessions`]
66    /// in `setup` (the channel is built before the proxy's
67    /// `SessionManager` exists). Lets inbound `client_request`s
68    /// (`ListTools`/`CallTool`/`ListResources`/`ReadResource`) run the
69    /// proxy's aggregated MCP ops by `response_id`.
70    sessions: OnceLock<Arc<SessionManager>>,
71}
72
73/// Cheaply-cloneable handle the proxy uses to speak over the WS.
74#[derive(Clone)]
75pub struct ReverseChannel(Arc<Inner>);
76
77impl std::fmt::Debug for ReverseChannel {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("ReverseChannel").finish_non_exhaustive()
80    }
81}
82
83impl ReverseChannel {
84    /// Build a channel. Returns the channel plus the receiver the API
85    /// drains (serializing each `server_request` onto the shared WS sink).
86    pub fn new() -> (Self, mpsc::UnboundedReceiver<ServerRequest>) {
87        let (tx, rx) = mpsc::unbounded_channel();
88        let inner = Inner {
89            tx,
90            pending: DashMap::new(),
91            list_changed: DashMap::new(),
92            sessions: OnceLock::new(),
93        };
94        (Self(Arc::new(inner)), rx)
95    }
96
97    /// Late-bind the proxy's session registry so inbound MCP-op
98    /// `client_request`s can resolve a session by `response_id`. Called
99    /// once by `setup` (idempotent — first write wins).
100    pub(crate) fn wire_sessions(&self, sessions: Arc<SessionManager>) {
101        let _ = self.0.sessions.set(sessions);
102    }
103
104    /// Resolve a session for an inbound MCP-op `client_request`. Returns
105    /// a `(code, message)` error suitable for a `JsonRpcResult::Err` when
106    /// sessions aren't wired or no session exists for `response_id`.
107    /// Resolve a session for a client-request MCP op. A response id
108    /// whose initial connect is in flight parks here until the connect
109    /// finishes (see `SessionManager::get_or_wait`) instead of failing
110    /// with `-32001` — an upstream server may call back in while it is
111    /// itself being connected.
112    async fn lookup_session(
113        &self,
114        response_id: &str,
115    ) -> Result<Arc<Session>, (i64, String)> {
116        let sessions = self
117            .0
118            .sessions
119            .get()
120            .ok_or((-32603i64, "proxy sessions not wired".to_string()))?;
121        sessions
122            .get_or_wait(response_id)
123            .await
124            .ok_or_else(|| (-32001i64, format!("unknown session for response id {response_id:?}")))
125    }
126
127    /// Emit a `server_request` and await its matching `server_response`,
128    /// bounded by the CALLER-supplied per-op `timeout` — `None` awaits
129    /// with no deadline (resolves on reply, errors on channel drop).
130    /// ws-MCP ops pass the per-request call timeout, connects pass the
131    /// connect timeout, laboratory transfers and drops pass `None`.
132    /// `id` is minted here; the API's recv loop routes the reply back
133    /// via [`Self::deliver_response`].
134    async fn request(
135        &self,
136        payload: server_request::Payload,
137        headers: IndexMap<String, String>,
138        timeout: Option<Duration>,
139    ) -> Result<ServerResponse, McpError> {
140        let id = uuid::Uuid::new_v4().to_string();
141        let (resp_tx, resp_rx) = oneshot::channel();
142        self.0.pending.insert(id.clone(), resp_tx);
143        let request = ServerRequest {
144            id: id.clone(),
145            headers,
146            payload,
147        };
148        if self.0.tx.send(request).is_err() {
149            self.0.pending.remove(&id);
150            return Err(transport_error("reverse channel closed before send"));
151        }
152        let received = match timeout {
153            Some(timeout) => match tokio::time::timeout(timeout, resp_rx).await
154            {
155                Ok(received) => received,
156                Err(_) => {
157                    self.0.pending.remove(&id);
158                    return Err(transport_error(
159                        "reverse channel timed out waiting for response",
160                    ));
161                }
162            },
163            None => resp_rx.await,
164        };
165        match received {
166            Ok(response) => Ok(response),
167            Err(_) => {
168                self.0.pending.remove(&id);
169                Err(transport_error("reverse channel dropped before response"))
170            }
171        }
172    }
173
174    /// Best-effort `Drop` server-request for `response_id`: tells the CLI
175    /// to tear down the whole response-id bucket (connections + plugin
176    /// subprocesses). The reply (`DropResult`) is discarded; transport
177    /// errors / timeouts are ignored — teardown is fire-and-forget.
178    pub(crate) async fn drop_response(&self, response_id: String) {
179        let _ = self
180            .request(
181                server_request::Payload::Drop(server_request::DropRequest { response_id }),
182                IndexMap::new(),
183                None,
184            )
185            .await;
186    }
187
188    /// `LaboratoryExportBegin`: start an export on the conduit and get
189    /// its transfer id. Each transfer op below is ONE id-correlated
190    /// request/response exchange awaited WITHOUT a deadline — laboratory
191    /// transfers are timeout-free unconditionally (never bounded by the
192    /// per-request MCP call timeout).
193    /// `LaboratoryTransfer`: hand the WHOLE cross-host client-to-client
194    /// transfer to the CLI daemon, which drives the export/import
195    /// splice itself. One request, one `{bytes}` reply — the proxy
196    /// never touches payload bytes. Timeout-free like every transfer
197    /// op.
198    #[allow(clippy::too_many_arguments)]
199    pub async fn laboratory_transfer(
200        &self,
201        request: server_request::LaboratoryTransferRequest,
202    ) -> Result<u64, McpError> {
203        let response = self
204            .request(
205                server_request::Payload::LaboratoryTransfer(request),
206                IndexMap::new(),
207                None,
208            )
209            .await?;
210        match response.payload {
211            server_response::Payload::LaboratoryTransfer(result) => {
212                unwrap_rpc("laboratory_transfer", result).map(|r| r.bytes)
213            }
214            other => Err(variant_mismatch(
215                "laboratory_transfer",
216                "laboratory_transfer",
217                &other,
218            )),
219        }
220    }
221
222    /// `LaboratoryLocalTransfer`: both endpoints share one (machine,
223    /// state) — the CLI daemon forwards this verbatim to that one
224    /// laboratory host, which pipes the bytes container-to-container.
225    pub async fn laboratory_local_transfer(
226        &self,
227        request: server_request::LaboratoryLocalTransferRequest,
228    ) -> Result<u64, McpError> {
229        let response = self
230            .request(
231                server_request::Payload::LaboratoryLocalTransfer(request),
232                IndexMap::new(),
233                None,
234            )
235            .await?;
236        match response.payload {
237            server_response::Payload::LaboratoryLocalTransfer(result) => {
238                unwrap_rpc("laboratory_local_transfer", result).map(|r| r.bytes)
239            }
240            other => Err(variant_mismatch(
241                "laboratory_local_transfer",
242                "laboratory_local_transfer",
243                &other,
244            )),
245        }
246    }
247
248    pub async fn laboratory_export_begin(
249        &self,
250        laboratory_id: String,
251        machine: Option<String>,
252        machine_state: Option<String>,
253        path: String,
254    ) -> Result<String, McpError> {
255        let response = self
256            .request(
257                server_request::Payload::LaboratoryExportBegin(
258                    server_request::LaboratoryExportBeginRequest {
259                        laboratory_id,
260                        machine,
261                        machine_state,
262                        path,
263                    },
264                ),
265                IndexMap::new(),
266                None,
267            )
268            .await?;
269        match response.payload {
270            server_response::Payload::LaboratoryExportBegin(result) => {
271                unwrap_rpc("laboratory_export_begin", result)
272                    .map(|r| r.transfer_id)
273            }
274            other => Err(variant_mismatch(
275                "laboratory_export_begin",
276                "laboratory_export_begin",
277                &other,
278            )),
279        }
280    }
281
282    /// `LaboratoryExportRead`: pull the next chunk.
283    pub async fn laboratory_export_read(
284        &self,
285        transfer_id: String,
286    ) -> Result<server_response::LaboratoryExportChunk, McpError> {
287        let response = self
288            .request(
289                server_request::Payload::LaboratoryExportRead(
290                    server_request::LaboratoryExportReadRequest { transfer_id },
291                ),
292                IndexMap::new(),
293                None,
294            )
295            .await?;
296        match response.payload {
297            server_response::Payload::LaboratoryExportRead(result) => {
298                unwrap_rpc("laboratory_export_read", result)
299            }
300            other => Err(variant_mismatch(
301                "laboratory_export_read",
302                "laboratory_export_read",
303                &other,
304            )),
305        }
306    }
307
308    /// `LaboratoryExportAbort`: best-effort early cleanup.
309    pub async fn laboratory_export_abort(&self, transfer_id: String) {
310        let _ = self
311            .request(
312                server_request::Payload::LaboratoryExportAbort(
313                    server_request::LaboratoryExportAbortRequest { transfer_id },
314                ),
315                IndexMap::new(),
316                None,
317            )
318            .await;
319    }
320
321    /// `LaboratoryImportBegin`: start an import on the conduit and get
322    /// its transfer id.
323    pub async fn laboratory_import_begin(
324        &self,
325        laboratory_id: String,
326        machine: Option<String>,
327        machine_state: Option<String>,
328        path: String,
329    ) -> Result<String, McpError> {
330        let response = self
331            .request(
332                server_request::Payload::LaboratoryImportBegin(
333                    server_request::LaboratoryImportBeginRequest {
334                        laboratory_id,
335                        machine,
336                        machine_state,
337                        path,
338                    },
339                ),
340                IndexMap::new(),
341                None,
342            )
343            .await?;
344        match response.payload {
345            server_response::Payload::LaboratoryImportBegin(result) => {
346                unwrap_rpc("laboratory_import_begin", result)
347                    .map(|r| r.transfer_id)
348            }
349            other => Err(variant_mismatch(
350                "laboratory_import_begin",
351                "laboratory_import_begin",
352                &other,
353            )),
354        }
355    }
356
357    /// `LaboratoryImportWrite`: push one chunk.
358    pub async fn laboratory_import_write(
359        &self,
360        transfer_id: String,
361        data: String,
362    ) -> Result<(), McpError> {
363        let response = self
364            .request(
365                server_request::Payload::LaboratoryImportWrite(
366                    server_request::LaboratoryImportWriteRequest { transfer_id, data },
367                ),
368                IndexMap::new(),
369                None,
370            )
371            .await?;
372        match response.payload {
373            server_response::Payload::LaboratoryImportWrite(result) => {
374                unwrap_rpc("laboratory_import_write", result).map(|_| ())
375            }
376            other => Err(variant_mismatch(
377                "laboratory_import_write",
378                "laboratory_import_write",
379                &other,
380            )),
381        }
382    }
383
384    /// `LaboratoryImportEnd`: close the body and get the byte total.
385    pub async fn laboratory_import_end(
386        &self,
387        transfer_id: String,
388    ) -> Result<u64, McpError> {
389        let response = self
390            .request(
391                server_request::Payload::LaboratoryImportEnd(
392                    server_request::LaboratoryImportEndRequest { transfer_id },
393                ),
394                IndexMap::new(),
395                None,
396            )
397            .await?;
398        match response.payload {
399            server_response::Payload::LaboratoryImportEnd(result) => {
400                unwrap_rpc("laboratory_import_end", result).map(|r| r.bytes)
401            }
402            other => Err(variant_mismatch(
403                "laboratory_import_end",
404                "laboratory_import_end",
405                &other,
406            )),
407        }
408    }
409
410    /// `LaboratoryImportAbort`: best-effort early cleanup.
411    pub async fn laboratory_import_abort(&self, transfer_id: String) {
412        let _ = self
413            .request(
414                server_request::Payload::LaboratoryImportAbort(
415                    server_request::LaboratoryImportAbortRequest { transfer_id },
416                ),
417                IndexMap::new(),
418                None,
419            )
420            .await;
421    }
422
423    /// Hand a proxy-bound `server_response` (one of the 6 MCP variants)
424    /// back to the waiter that issued the matching request. Called by the
425    /// API's recv loop. Unknown id → dropped.
426    pub fn deliver_response(&self, response: ServerResponse) {
427        if let Some((_, tx)) = self.0.pending.remove(&response.id) {
428            let _ = tx.send(response);
429        }
430    }
431
432    /// Hand a proxy-bound `client_request` to the proxy.
433    ///
434    /// `McpListChanged` fires the registered list-changed callback for the
435    /// upstream. The MCP-op variants (`ListTools`/`CallTool`/
436    /// `ListResources`/`ReadResource`) resolve the session by
437    /// `response_id` and run the SAME shared [`crate::session::Session`]
438    /// code the HTTP endpoints use — fanning out / routing exactly as
439    /// `mcp::handle_tools_list` etc. — returning the normal MCP result.
440    /// NOTE: the `CallTool` path deliberately does NOT consult the queue
441    /// delegate (that splice is only for the regular HTTP `tools/call`).
442    /// Returns the ack/result the API writes back over the WS.
443    pub async fn deliver_client_request(
444        &self,
445        request: client_request::Request,
446    ) -> client_response::Response {
447        let client_request::Request { id, payload } = request;
448        match payload {
449            client_request::Payload::McpListChanged(change) => {
450                if let Some(cbs) = self.0.list_changed.get(&change.mcp_kind) {
451                    let cb = match change.kind {
452                        McpListChangedKind::Tools => cbs.0.clone(),
453                        McpListChangedKind::Resources => cbs.1.clone(),
454                    };
455                    drop(cbs);
456                    if let Some(cb) = cb {
457                        cb();
458                    }
459                }
460                client_response::Response::Ok { id }
461            }
462            // List params (cursor) are ignored, matching the HTTP
463            // `handle_tools_list` which fans out to every upstream.
464            // List params (cursor) are ignored, matching the HTTP
465            // `handle_tools_list`; `name`, when set, scopes the fan-out to
466            // the single server with that routing prefix.
467            client_request::Payload::ListTools {
468                response_id, name, ..
469            } => {
470                let result = match self.lookup_session(&response_id).await {
471                    Ok(session) => {
472                        match session.list_tools_filtered(None, name.as_deref()).await {
473                            Ok(result) => JsonRpcResult::Ok { result },
474                            Err(e) => rpc_err_result(-32603, format!("list_tools: {e}")),
475                        }
476                    }
477                    Err((code, message)) => rpc_err_result(code, message),
478                };
479                client_response::Response::ListTools { id, result }
480            }
481            client_request::Payload::ListResources {
482                response_id, name, ..
483            } => {
484                let result = match self.lookup_session(&response_id).await {
485                    Ok(session) => {
486                        match session.list_resources_filtered(None, name.as_deref()).await {
487                            Ok(result) => JsonRpcResult::Ok { result },
488                            Err(e) => rpc_err_result(-32603, format!("list_resources: {e}")),
489                        }
490                    }
491                    Err((code, message)) => rpc_err_result(code, message),
492                };
493                client_response::Response::ListResources { id, result }
494            }
495            client_request::Payload::ListServers { response_id } => {
496                let result = match self.lookup_session(&response_id).await {
497                    // Proxy-local aggregate — no upstream fan-out, can't fail.
498                    Ok(session) => JsonRpcResult::Ok {
499                        result: session.list_servers(),
500                    },
501                    Err((code, message)) => rpc_err_result(code, message),
502                };
503                client_response::Response::ListServers { id, result }
504            }
505            client_request::Payload::CallTool { response_id, params } => {
506                let result = match self.lookup_session(&response_id).await {
507                    // No queue delegate here — unlike the HTTP path, this
508                    // returns the upstream tool result verbatim.
509                    Ok(session) => match session.call_tool(&params).await {
510                        Ok(result) => JsonRpcResult::Ok { result },
511                        Err(crate::session::CallToolError::ToolNotFound(name)) => {
512                            rpc_err_result(-32601, format!("tool not found: {name}"))
513                        }
514                        Err(crate::session::CallToolError::Upstream(e)) => {
515                            rpc_err_result(-32603, format!("upstream call_tool: {e}"))
516                        }
517                    },
518                    Err((code, message)) => rpc_err_result(code, message),
519                };
520                client_response::Response::CallTool { id, result }
521            }
522            client_request::Payload::ReadResource { response_id, params } => {
523                let result = match self.lookup_session(&response_id).await {
524                    Ok(session) => match session.read_resource(&params.uri).await {
525                        Ok(result) => JsonRpcResult::Ok { result },
526                        Err(crate::session::ReadResourceError::ResourceNotFound(uri)) => {
527                            rpc_err_result(-32602, format!("resource not found: {uri}"))
528                        }
529                        Err(crate::session::ReadResourceError::Upstream(e)) => {
530                            rpc_err_result(-32603, format!("upstream read_resource: {e}"))
531                        }
532                    },
533                    Err((code, message)) => rpc_err_result(code, message),
534                };
535                client_response::Response::ReadResource { id, result }
536            }
537        }
538    }
539
540    fn set_tools_list_changed(&self, mcp_kind: McpKind, cb: ListChangedCb) {
541        let mut entry = self.0.list_changed.entry(mcp_kind).or_default();
542        entry.0 = Some(cb);
543    }
544
545    fn set_resources_list_changed(&self, mcp_kind: McpKind, cb: ListChangedCb) {
546        let mut entry = self.0.list_changed.entry(mcp_kind).or_default();
547        entry.1 = Some(cb);
548    }
549}
550
551/// A `ws://`-scheme upstream, reached over the [`ReverseChannel`]. Mirrors
552/// the slice of [`Connection`]'s interface the [`crate::session::Session`]
553/// uses, translating each op into a `server_request` carrying this
554/// upstream's [`McpKind`].
555pub struct WsUpstream {
556    channel: ReverseChannel,
557    mcp_kind: McpKind,
558    /// Per-MCP-CALL budget for this upstream's reverse-channel ops
559    /// (from the request's `X-MCP-CALL-TIMEOUT` via the proxy config).
560    /// `None` ⇒ calls wait forever. Never applied to the connect
561    /// (`initialize`) — that uses the connect timeout.
562    call_timeout: Option<Duration>,
563    /// The `ws://…` URL this upstream was dialed with (used for filtering).
564    pub url: String,
565    /// Upstream `Mcp-Session-Id` returned by the CLI on `initialize`.
566    pub session_id: String,
567    /// Upstream `server_info.name` / `.version` from the `initialize`
568    /// reply — feeds the session's routing-prefix derivation.
569    server_name: String,
570    server_version: String,
571    /// The upstream's full `initialize` reply (capabilities, server_info,
572    /// instructions, protocol version) — kept verbatim so `servers/list`
573    /// can report it. `Connection` exposes the same via its own
574    /// `initialize_result`.
575    initialize_result: objectiveai_sdk::mcp::initialize_result::InitializeResult,
576    /// Typed laboratory identity from the explicit `X-MCP-Laboratories`
577    /// marker — the authoritative "this upstream is a laboratory" signal,
578    /// `None` for non-laboratory upstreams. Never derived from the URL.
579    laboratory: Option<objectiveai_sdk::laboratories::Laboratory>,
580    /// Whether the upstream advertised the `tools` / `resources`
581    /// capability in its `initialize` reply. We must NOT issue
582    /// `tools/list` / `resources/list` against an upstream that didn't
583    /// advertise the capability: many servers (incl. the test
584    /// fixtures) 404 the un-advertised method, and a hard error there
585    /// fails the whole aggregate — and, on the post-init health probe,
586    /// fails the connect and churns endless re-`initialize`s. Mirrors
587    /// `mcp::Connection::has_{tools,resources}_cap`.
588    has_tools_cap: bool,
589    has_resources_cap: bool,
590    /// Persistent per-upstream headers captured at connect: the per-URL
591    /// set (`Authorization`, custom `X-*`, `X-OBJECTIVEAI-ARGUMENTS`)
592    /// plus whatever identity headers were present at dial. Never
593    /// mutated after connect — mirrors the SDK `Connection`'s base
594    /// `headers`. The transient subset is overridden per request by
595    /// `extra_headers`; the per-URL subset has no overlay key, so it
596    /// always survives on every request.
597    base_headers: IndexMap<String, String>,
598    /// Mutable transient-identity overlay, full-replaced every turn by
599    /// `apply_transient_headers` → `set_extra_headers`. Overrides
600    /// `base_headers` per key (mirrors `Connection::extra_headers`).
601    /// Starts empty: until the first refresh, `base_headers` alone
602    /// carries the dial-time identity headers.
603    extra_headers: RwLock<IndexMap<String, String>>,
604}
605
606impl std::fmt::Debug for WsUpstream {
607    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
608        f.debug_struct("WsUpstream")
609            .field("url", &self.url)
610            .field("session_id", &self.session_id)
611            .finish_non_exhaustive()
612    }
613}
614
615impl WsUpstream {
616    /// Headers to stamp on every outbound `server_request`, mirroring
617    /// `Connection::build_request_headers` exactly: the persistent
618    /// `base_headers` first, then the `extra_headers` transient overlay
619    /// (overrides per key), then this upstream's `Mcp-Session-Id` last
620    /// (so it can never be shadowed). Per-URL headers live only in
621    /// `base_headers` (no overlay key collides), so they're present on
622    /// EVERY request — identical to the HTTP path.
623    async fn headers(&self) -> IndexMap<String, String> {
624        let mut h = self.base_headers.clone();
625        for (k, v) in self.extra_headers.read().await.iter() {
626            h.insert(k.clone(), v.clone());
627        }
628        h.insert(
629            crate::upstream::MCP_SESSION_ID_KEY.to_string(),
630            self.session_id.clone(),
631        );
632        h
633    }
634
635    pub async fn list_tools(&self) -> Result<Arc<Vec<Tool>>, Arc<McpError>> {
636        // Capability gate — an upstream that didn't advertise `tools`
637        // has no `tools/list`; calling it anyway 404s on most servers.
638        if !self.has_tools_cap {
639            return Ok(Arc::new(Vec::new()));
640        }
641        let headers = self.headers().await;
642        let response = self
643            .channel
644            .request(
645                server_request::Payload::ToolsList {
646                    mcp_kind: self.mcp_kind.clone(),
647                    params: ListToolsRequest { cursor: None },
648                },
649                headers,
650                self.call_timeout,
651            )
652            .await
653            .map_err(Arc::new)?;
654        match response.payload {
655            server_response::Payload::ToolsList { result, .. } => {
656                Ok(Arc::new(unwrap_rpc(&self.url, result).map_err(Arc::new)?.tools))
657            }
658            other => Err(Arc::new(variant_mismatch(&self.url, "tools_list", &other))),
659        }
660    }
661
662    pub async fn list_resources(&self) -> Result<Arc<Vec<Resource>>, Arc<McpError>> {
663        // Capability gate — an upstream that didn't advertise
664        // `resources` has no `resources/list`; calling it anyway 404s
665        // on most servers (e.g. the tools-only plugin fixtures).
666        if !self.has_resources_cap {
667            return Ok(Arc::new(Vec::new()));
668        }
669        let headers = self.headers().await;
670        let response = self
671            .channel
672            .request(
673                server_request::Payload::ResourcesList {
674                    mcp_kind: self.mcp_kind.clone(),
675                    params: ListResourcesRequest { cursor: None },
676                },
677                headers,
678                self.call_timeout,
679            )
680            .await
681            .map_err(Arc::new)?;
682        match response.payload {
683            server_response::Payload::ResourcesList { result, .. } => {
684                Ok(Arc::new(unwrap_rpc(&self.url, result).map_err(Arc::new)?.resources))
685            }
686            other => Err(Arc::new(variant_mismatch(&self.url, "resources_list", &other))),
687        }
688    }
689
690    pub async fn call_tool(
691        &self,
692        params: &CallToolRequestParams,
693    ) -> Result<CallToolResult, McpError> {
694        let headers = self.headers().await;
695        let response = self
696            .channel
697            .request(
698                server_request::Payload::ToolsCall {
699                    mcp_kind: self.mcp_kind.clone(),
700                    params: params.clone(),
701                },
702                headers,
703                self.call_timeout,
704            )
705            .await?;
706        match response.payload {
707            server_response::Payload::ToolsCall { result, .. } => unwrap_rpc(&self.url, result),
708            other => Err(variant_mismatch(&self.url, "tools_call", &other)),
709        }
710    }
711
712    pub async fn read_resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
713        let headers = self.headers().await;
714        let response = self
715            .channel
716            .request(
717                server_request::Payload::ResourcesRead {
718                    mcp_kind: self.mcp_kind.clone(),
719                    params: ReadResourceRequestParams {
720                        uri: uri.to_string(),
721                    },
722                },
723                headers,
724                self.call_timeout,
725            )
726            .await?;
727        match response.payload {
728            server_response::Payload::ResourcesRead { result, .. } => unwrap_rpc(&self.url, result),
729            other => Err(variant_mismatch(&self.url, "resources_read", &other)),
730        }
731    }
732
733    pub async fn delete(&self) -> Result<(), McpError> {
734        let headers = self.headers().await;
735        let response = self
736            .channel
737            .request(
738                server_request::Payload::SessionTerminate {
739                    mcp_kind: self.mcp_kind.clone(),
740                },
741                headers,
742                self.call_timeout,
743            )
744            .await?;
745        match response.payload {
746            server_response::Payload::SessionTerminate { result, .. } => unwrap_rpc(&self.url, result),
747            other => Err(variant_mismatch(&self.url, "session_terminate", &other)),
748        }
749    }
750
751    pub fn set_on_tools_list_changed<F>(&self, callback: F)
752    where
753        F: Fn() + Send + Sync + 'static,
754    {
755        self.channel
756            .set_tools_list_changed(self.mcp_kind.clone(), Arc::new(callback));
757    }
758
759    pub fn set_on_resources_list_changed<F>(&self, callback: F)
760    where
761        F: Fn() + Send + Sync + 'static,
762    {
763        self.channel
764            .set_resources_list_changed(self.mcp_kind.clone(), Arc::new(callback));
765    }
766
767    pub async fn set_extra_headers(&self, extras: IndexMap<String, String>) {
768        *self.extra_headers.write().await = extras;
769    }
770}
771
772/// A per-upstream handle: HTTP [`Connection`] or WS [`WsUpstream`]. Exposes
773/// exactly the surface [`crate::session::Session`] + `handle_delete` use.
774#[derive(Debug)]
775pub enum Upstream {
776    Http(Connection),
777    Ws(WsUpstream),
778}
779
780impl Upstream {
781    /// Whether this upstream is reached over the `client_objectiveai_mcp`
782    /// reverse channel (a `ws://` upstream) rather than plain HTTP.
783    pub fn is_ws(&self) -> bool {
784        matches!(self, Upstream::Ws(_))
785    }
786
787    pub fn url(&self) -> &str {
788        match self {
789            Upstream::Http(c) => &c.url,
790            Upstream::Ws(w) => &w.url,
791        }
792    }
793
794    pub fn session_id(&self) -> &str {
795        match self {
796            Upstream::Http(c) => &c.session_id,
797            Upstream::Ws(w) => &w.session_id,
798        }
799    }
800
801    /// Upstream `server_info.name` — used to derive the session's routing
802    /// prefix. (`Connection` exposes it via `initialize_result`.)
803    pub fn server_name(&self) -> &str {
804        match self {
805            Upstream::Http(c) => &c.initialize_result.server_info.name,
806            Upstream::Ws(w) => &w.server_name,
807        }
808    }
809
810    /// Upstream `server_info.version` — the prefix collision tie-breaker.
811    pub fn server_version(&self) -> &str {
812        match self {
813            Upstream::Http(c) => &c.initialize_result.server_info.version,
814            Upstream::Ws(w) => &w.server_version,
815        }
816    }
817
818    /// The upstream's full `initialize` reply (capabilities, server_info,
819    /// instructions, protocol version) — used by `servers/list`.
820    pub fn initialize_result(
821        &self,
822    ) -> &objectiveai_sdk::mcp::initialize_result::InitializeResult {
823        match self {
824            Upstream::Http(c) => &c.initialize_result,
825            Upstream::Ws(w) => &w.initialize_result,
826        }
827    }
828
829    /// The laboratory this upstream IS, if any — for `servers/list` and
830    /// `laboratory_transfer`.
831    ///
832    /// Read from the explicit, typed laboratory marker the API supplied
833    /// (`X-MCP-Laboratories`), NOT inferred by string-parsing the
834    /// `ws://laboratory/{id}` URL. HTTP upstreams and unmarked websocket
835    /// upstreams (the primary `objectiveai` MCP, plugins) are `None`.
836    pub fn laboratory(&self) -> Option<objectiveai_sdk::laboratories::Laboratory> {
837        match self {
838            Upstream::Http(_) => None,
839            Upstream::Ws(w) => w.laboratory.clone(),
840        }
841    }
842
843    /// The plugin this upstream IS, if any — for `servers/list`. Same
844    /// transport+kind gating as [`Self::laboratory`]: only a websocket
845    /// upstream whose `McpKind` is `Plugin` maps to a [`Plugin`]; HTTP and
846    /// non-plugin websocket kinds are `None`.
847    pub fn plugin(&self) -> Option<objectiveai_sdk::mcp::server::Plugin> {
848        match self {
849            Upstream::Http(_) => None,
850            Upstream::Ws(w) => match &w.mcp_kind {
851                McpKind::Plugin {
852                    owner,
853                    name,
854                    version,
855                    mcp,
856                } => Some(objectiveai_sdk::mcp::server::Plugin {
857                    owner: owner.clone(),
858                    name: name.clone(),
859                    version: version.clone(),
860                    mcp: mcp.clone(),
861                }),
862                McpKind::ObjectiveAi | McpKind::Laboratory { .. } => None,
863            },
864        }
865    }
866
867    /// The session reverse channel this upstream rides, for proxy-level
868    /// ops that aren't a per-upstream MCP call (e.g. laboratory transfer,
869    /// which spans two laboratories on the same conduit). `None` for HTTP
870    /// upstreams, which have no reverse channel.
871    pub fn reverse_channel(&self) -> Option<&ReverseChannel> {
872        match self {
873            Upstream::Http(_) => None,
874            Upstream::Ws(w) => Some(&w.channel),
875        }
876    }
877
878    pub async fn list_tools(&self) -> Result<Arc<Vec<Tool>>, Arc<McpError>> {
879        match self {
880            Upstream::Http(c) => c.list_tools().await,
881            Upstream::Ws(w) => w.list_tools().await,
882        }
883    }
884
885    pub async fn list_resources(&self) -> Result<Arc<Vec<Resource>>, Arc<McpError>> {
886        match self {
887            Upstream::Http(c) => c.list_resources().await,
888            Upstream::Ws(w) => w.list_resources().await,
889        }
890    }
891
892    pub async fn call_tool(
893        &self,
894        params: &CallToolRequestParams,
895    ) -> Result<CallToolResult, McpError> {
896        match self {
897            Upstream::Http(c) => c.call_tool(params).await,
898            Upstream::Ws(w) => w.call_tool(params).await,
899        }
900    }
901
902    pub async fn read_resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
903        match self {
904            Upstream::Http(c) => c.read_resource(uri).await,
905            Upstream::Ws(w) => w.read_resource(uri).await,
906        }
907    }
908
909    pub async fn delete(&self) -> Result<(), McpError> {
910        match self {
911            Upstream::Http(c) => c.delete().await,
912            Upstream::Ws(w) => w.delete().await,
913        }
914    }
915
916    pub fn set_on_tools_list_changed<F>(&self, callback: F)
917    where
918        F: Fn() + Send + Sync + 'static,
919    {
920        match self {
921            Upstream::Http(c) => c.set_on_tools_list_changed(callback),
922            Upstream::Ws(w) => w.set_on_tools_list_changed(callback),
923        }
924    }
925
926    pub fn set_on_resources_list_changed<F>(&self, callback: F)
927    where
928        F: Fn() + Send + Sync + 'static,
929    {
930        match self {
931            Upstream::Http(c) => c.set_on_resources_list_changed(callback),
932            Upstream::Ws(w) => w.set_on_resources_list_changed(callback),
933        }
934    }
935
936    pub async fn set_extra_headers(&self, extras: IndexMap<String, String>) {
937        match self {
938            Upstream::Http(c) => c.set_extra_headers(extras).await,
939            Upstream::Ws(w) => w.set_extra_headers(extras).await,
940        }
941    }
942}
943
944/// Parse a `ws://objectiveai` / `ws:///owner/name/version/mcp` URL into
945/// its [`McpKind`]. Returns `None` for any other shape.
946///
947/// NOTE: laboratories are deliberately NOT parsed here. A laboratory's
948/// `McpKind` comes from the explicit, typed `X-MCP-Laboratories` marker
949/// (see `crate::upstream`), never from string-matching the URL — so the
950/// proxy's notion of "this is a laboratory" has a single authoritative
951/// source.
952pub fn parse_ws_mcp_kind(url: &str) -> Option<McpKind> {
953    let rest = url.strip_prefix("ws://")?;
954    // Drop any `?query` (plugin args ride there, parsed separately).
955    let rest = rest.split('?').next().unwrap_or(rest);
956    // `ws://objectiveai` → host "objectiveai", no path.
957    if rest == "objectiveai" {
958        return Some(McpKind::ObjectiveAi);
959    }
960    // `ws:///owner/name/version/mcp` → empty host, leading '/'.
961    let path = rest.strip_prefix('/')?;
962    let parts: Vec<&str> = path.split('/').collect();
963    if let [owner, name, version, mcp] = parts.as_slice() {
964        if !owner.is_empty() && !name.is_empty() && !version.is_empty() && !mcp.is_empty() {
965            return Some(McpKind::Plugin {
966                owner: (*owner).to_string(),
967                name: (*name).to_string(),
968                version: (*version).to_string(),
969                mcp: (*mcp).to_string(),
970            });
971        }
972    }
973    None
974}
975
976/// `initialize` a `ws://` upstream over `channel` and build its
977/// [`WsUpstream`]. `headers` is the full set sent on the `initialize`
978/// request — the session-global transient identity headers, plus (on
979/// resume) the upstream `Mcp-Session-Id` and any auth. `args` carries
980/// plugin init arguments (empty for `objectiveai`).
981///
982/// `connect_timeout` bounds the `initialize` round-trip (the per-request
983/// call timeout NEVER applies to connects); `call_timeout` is stored on
984/// the [`WsUpstream`] for every later op.
985pub async fn connect_ws(
986    channel: ReverseChannel,
987    url: String,
988    mcp_kind: McpKind,
989    args: IndexMap<String, Option<String>>,
990    mut headers: IndexMap<String, String>,
991    laboratory: Option<objectiveai_sdk::laboratories::Laboratory>,
992    connect_timeout: Option<Duration>,
993    call_timeout: Option<Duration>,
994) -> Result<WsUpstream, McpError> {
995    let response = channel
996        .request(
997            server_request::Payload::Initialize {
998                mcp_kind: mcp_kind.clone(),
999                params: InitializeRequest { args },
1000            },
1001            headers.clone(),
1002            connect_timeout,
1003        )
1004        .await?;
1005    let reply = match response.payload {
1006        server_response::Payload::Initialize { result, .. } => unwrap_rpc(&url, result)?,
1007        other => return Err(variant_mismatch(&url, "initialize", &other)),
1008    };
1009    // The per-request stamped set drops the resume `Mcp-Session-Id`
1010    // ([`WsUpstream::headers`] re-adds whatever the upstream just minted)
1011    // but keeps the transient identity + auth so the post-init health
1012    // probe + every later call still pass the conduit's transient check.
1013    headers.shift_remove(crate::upstream::MCP_SESSION_ID_KEY);
1014    let session_id = reply.mcp_session_id;
1015    let initialize_result = reply.result;
1016    let has_tools_cap = initialize_result.capabilities.tools.is_some();
1017    let has_resources_cap = initialize_result.capabilities.resources.is_some();
1018    let server_name = initialize_result.server_info.name.clone();
1019    let server_version = initialize_result.server_info.version.clone();
1020    Ok(WsUpstream {
1021        channel,
1022        mcp_kind,
1023        call_timeout,
1024        url,
1025        session_id,
1026        server_name,
1027        server_version,
1028        initialize_result,
1029        laboratory,
1030        has_tools_cap,
1031        has_resources_cap,
1032        // The connect-time set (per-URL ∪ dial-time identity) is the
1033        // persistent base; the transient overlay starts empty and is
1034        // filled by the first `set_extra_headers`. Mirrors the SDK
1035        // `Connection`, where connect headers are the base and
1036        // `extra_headers` begins empty.
1037        base_headers: headers,
1038        extra_headers: RwLock::new(IndexMap::new()),
1039    })
1040}
1041
1042fn unwrap_rpc<R>(url: &str, result: JsonRpcResult<R>) -> Result<R, McpError> {
1043    match result {
1044        JsonRpcResult::Ok { result } => Ok(result),
1045        JsonRpcResult::Err {
1046            code,
1047            message,
1048            data,
1049        } => Err(McpError::JsonRpc {
1050            url: url.to_string(),
1051            code,
1052            message,
1053            data,
1054        }),
1055    }
1056}
1057
1058fn transport_error(message: &str) -> McpError {
1059    McpError::MalformedResponse {
1060        url: "ws".to_string(),
1061        message: message.to_string(),
1062    }
1063}
1064
1065/// Build a `JsonRpcResult::Err` for an inbound MCP-op `client_request`
1066/// (`deliver_client_request`). Generic over the result type so each
1067/// op's reply variant infers `R`.
1068fn rpc_err_result<R>(code: i64, message: String) -> JsonRpcResult<R> {
1069    JsonRpcResult::Err {
1070        code,
1071        message,
1072        data: None,
1073    }
1074}
1075
1076fn variant_mismatch(url: &str, expected: &str, got: &server_response::Payload) -> McpError {
1077    McpError::MalformedResponse {
1078        url: url.to_string(),
1079        message: format!(
1080            "reverse channel returned wrong payload variant: expected {expected}, got {}",
1081            got_variant_name(got),
1082        ),
1083    }
1084}
1085
1086fn got_variant_name(p: &server_response::Payload) -> &'static str {
1087    use server_response::Payload as P;
1088    match p {
1089        P::Initialize { .. } => "initialize",
1090        P::ToolsList { .. } => "tools_list",
1091        P::ToolsCall { .. } => "tools_call",
1092        P::ResourcesList { .. } => "resources_list",
1093        P::ResourcesRead { .. } => "resources_read",
1094        P::SessionTerminate { .. } => "session_terminate",
1095        P::ReadMessageQueue(_) => "read_message_queue",
1096        P::Retrieve(_) => "retrieve",
1097        P::Script(_) => "script",
1098        P::Drop(_) => "drop",
1099        P::LaboratoryTransfer(_) => "laboratory_transfer",
1100        P::LaboratoryLocalTransfer(_) => "laboratory_local_transfer",
1101        P::LaboratoryExportBegin(_) => "laboratory_export_begin",
1102        P::LaboratoryExportRead(_) => "laboratory_export_read",
1103        P::LaboratoryExportAbort(_) => "laboratory_export_abort",
1104        P::LaboratoryImportBegin(_) => "laboratory_import_begin",
1105        P::LaboratoryImportWrite(_) => "laboratory_import_write",
1106        P::LaboratoryImportEnd(_) => "laboratory_import_end",
1107        P::LaboratoryImportAbort(_) => "laboratory_import_abort",
1108    }
1109}