Skip to main content

objectiveai_sdk/mcp/
connection.rs

1//! MCP connection for communicating with an MCP server.
2//!
3//! [`Connection`] is a cheaply-clonable handle around an internal
4//! [`ConnectionInner`]. The last drop of the inner `Arc` drops
5//! [`ConnectionInner`]'s `_listener_cancel_guard` field (a
6//! [`tokio_util::sync::DropGuard`]), which cancels the listener task's
7//! [`tokio_util::sync::CancellationToken`] — the SSE listener exits the
8//! instant any in-flight reconnect, sleep, or read is cancelled, with no
9//! zombie 401 retries against a now-dead proxy session.
10
11use std::ops::Deref;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, RwLock as StdRwLock, Weak};
14use std::time::Duration;
15
16use indexmap::IndexMap;
17use tokio::sync::RwLock;
18use tokio_util::sync::{CancellationToken, DropGuard};
19
20/// Callback fired by [`Connection`] when the upstream MCP server emits
21/// `notifications/tools/list_changed` or `notifications/resources/list_changed`.
22///
23/// **Timing:** runs after the corresponding cache's write lock is taken
24/// but *before* the network paginate that replaces it. That ordering
25/// matches the moment the staleness window opens — anyone blocked on the
26/// read lock won't return until the new list lands. The callback should
27/// not call back into `list_tools` / `list_resources`: doing so would
28/// re-take the lock the listener already holds and deadlock.
29///
30/// Stored behind an `Arc` so the listener task can cheaply clone it out
31/// of the lock and call it without holding the read guard.
32pub type ListChangedCallback = Arc<dyn Fn() + Send + Sync + 'static>;
33
34/// A registered-or-not callback slot. Wrapper so [`ConnectionInner`] can
35/// keep `#[derive(Debug)]` (a raw `dyn Fn` isn't `Debug`).
36struct CallbackSlot(StdRwLock<Option<ListChangedCallback>>);
37
38impl CallbackSlot {
39    fn new() -> Self {
40        Self(StdRwLock::new(None))
41    }
42
43    fn set(&self, callback: ListChangedCallback) {
44        *self.0.write().unwrap() = Some(callback);
45    }
46
47    /// Cheap clone-out of the current callback (if any). The `Arc` clone
48    /// lets us release the read guard before invoking the callback.
49    fn get(&self) -> Option<ListChangedCallback> {
50        self.0.read().unwrap().clone()
51    }
52}
53
54impl std::fmt::Debug for CallbackSlot {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        let set = self.0.read().map(|g| g.is_some()).unwrap_or(false);
57        f.debug_struct("CallbackSlot").field("set", &set).finish()
58    }
59}
60
61/// An active connection to an MCP server using the Streamable HTTP transport.
62///
63/// Cheaply clonable (one `Arc` bump). When the last clone is dropped, the
64/// inner `Arc` ref count hits zero, the `_listener_cancel_guard` field is
65/// dropped, and the SSE listener task is
66/// cancelled — exiting any in-flight `lines.next_line()`, reconnect
67/// `send()`, or backoff `sleep` *immediately* without retrying against
68/// the now-dead proxy session.
69///
70/// Use the public methods (`list_tools`, `call_tool`, `list_resources`,
71/// `read_resource`, `call_tool_as_message`, `tool_key`) for the upstream
72/// MCP protocol surface. The inner state ([`ConnectionInner`]) is also
73/// reachable via `Deref` for read-only field access (e.g.
74/// `connection.url`, `connection.initialize_result.server_info.name`),
75/// but its methods are private — you must go through `Connection`.
76#[derive(Debug)]
77pub struct Connection {
78    inner: Arc<ConnectionInner>,
79}
80
81impl Clone for Connection {
82    fn clone(&self) -> Self {
83        Self {
84            inner: Arc::clone(&self.inner),
85        }
86    }
87}
88
89// No `Drop` for `Connection`: cancellation happens deterministically
90// when the last `Arc<ConnectionInner>` clone is dropped, which drops
91// the `_listener_cancel_guard` field and cancels the listener token.
92
93impl Deref for Connection {
94    type Target = ConnectionInner;
95    fn deref(&self) -> &ConnectionInner {
96        &self.inner
97    }
98}
99
100impl Connection {
101    /// Tear this connection down explicitly.
102    ///
103    /// 1. Cancels the long-lived list-changed listener task immediately
104    ///    (drops the [`DropGuard`] in
105    ///    [`ConnectionInner::_listener_cancel_guard`]), so by the time
106    ///    the HTTP DELETE goes out the listener isn't still holding an
107    ///    SSE read open against the upstream we're about to close.
108    /// 2. Issues `DELETE /` to the upstream with this connection's
109    ///    `Mcp-Session-Id` and the same merged header set every other
110    ///    RPC stamps. Reuses [`ConnectionInner::call_timeout`].
111    /// 3. Treats `404 / 401 / 403` as success — the upstream is
112    ///    unreachable from these credentials anyway, which is the
113    ///    desired terminal state. Other non-2xx surfaces as
114    ///    [`super::Error::BadStatus`].
115    ///
116    /// Takes `&self`: the listener cancel is in-place, and dropping
117    /// the surrounding `Arc<ConnectionInner>` (which closes the rest
118    /// of the connection's owned state) is the caller's responsibility
119    /// — usually by dropping the `Arc<Session>` holding it. Stateless
120    /// callers that don't hold a `Connection` should use
121    /// [`Client::delete`](super::Client::delete) instead.
122    ///
123    /// **In-flight RPC ordering.** This method does not block on
124    /// in-flight `call_tool` / `read_resource` / `list_tools` /
125    /// `list_resources` calls on the same connection. If one is
126    /// outstanding when `delete` lands, the upstream may see DELETE
127    /// before the RPC's reply makes it back; the in-flight call then
128    /// surfaces as a closed-connection error to whoever started it.
129    /// That's the spec-correct order (client said terminate) — drain
130    /// on the caller side first if you need different semantics.
131    pub async fn delete(&self) -> Result<(), super::Error> {
132        // 1. Drop the listener-cancel guard. Releasing the `DropGuard`
133        //    cancels the sibling `CancellationToken` the listener task
134        //    holds; the listener `tokio::select!`s against it on every
135        //    blocking await and exits inside one scheduler tick.
136        if let Ok(mut guard) = self.inner._listener_cancel_guard.lock() {
137            let _ = guard.take();
138        }
139
140        // 2. Build + send HTTP DELETE. Mirrors `Client::connect_once`'s
141        //    request-stamp shape: header loop first, explicit
142        //    `Mcp-Session-Id` always wins.
143        let request = super::apply_timeout(
144            self.inner.http_client.delete(&self.inner.url),
145            self.inner.call_timeout,
146        )
147        .headers(self.inner.build_request_headers(None, None).await);
148        let response = request.send().await.map_err(|source| {
149            super::Error::Request {
150                url: self.inner.url.clone(),
151                source,
152            }
153        })?;
154
155        // 3. 404 / 401 / 403 → success; other non-2xx → real error.
156        let status = response.status();
157        if matches!(
158            status,
159            reqwest::StatusCode::NOT_FOUND
160                | reqwest::StatusCode::UNAUTHORIZED
161                | reqwest::StatusCode::FORBIDDEN
162        ) {
163            return Ok(());
164        }
165        if !status.is_success() {
166            let body = response.text().await.unwrap_or_default();
167            return Err(super::Error::BadStatus {
168                url: self.inner.url.clone(),
169                code: status,
170                body: body.chars().take(800).collect(),
171            });
172        }
173        Ok(())
174    }
175
176    pub(super) async fn new(
177        http_client: reqwest::Client,
178        url: String,
179        session_id: String,
180        headers: IndexMap<String, String>,
181        backoff_current_interval: Duration,
182        backoff_initial_interval: Duration,
183        backoff_randomization_factor: f64,
184        backoff_multiplier: f64,
185        backoff_max_interval: Duration,
186        backoff_max_elapsed_time: Duration,
187        call_timeout: Option<Duration>,
188        initialize_result: super::initialize_result::InitializeResult,
189        initial_sse_lines: Option<super::LinesStream>,
190    ) -> Self {
191        let inner = ConnectionInner::new(
192            http_client,
193            url,
194            session_id,
195            headers,
196            backoff_current_interval,
197            backoff_initial_interval,
198            backoff_randomization_factor,
199            backoff_multiplier,
200            backoff_max_interval,
201            backoff_max_elapsed_time,
202            call_timeout,
203            initialize_result,
204            initial_sse_lines,
205        )
206        .await;
207        Self { inner }
208    }
209
210    #[cfg(test)]
211    pub(crate) fn new_for_test(name: String, url: String) -> Self {
212        Self {
213            inner: ConnectionInner::new_for_test(name, url),
214        }
215    }
216
217    #[cfg(test)]
218    pub(crate) fn new_for_test_with_caps(
219        name: String,
220        url: String,
221        capabilities: super::initialize_result::ServerCapabilities,
222    ) -> Self {
223        Self {
224            inner: ConnectionInner::new_for_test_with_caps(
225                name,
226                url,
227                capabilities,
228            ),
229        }
230    }
231
232    /// Returns a key identifying this connection for tool namespacing.
233    pub fn tool_key(&self) -> String {
234        self.inner.tool_key()
235    }
236
237    /// Returns the session ID for this connection.
238    pub fn session_id(&self) -> &str {
239        self.inner.session_id()
240    }
241
242    /// Returns all tools from the upstream server.
243    pub async fn list_tools(
244        &self,
245    ) -> Result<Arc<Vec<super::tool::Tool>>, Arc<super::Error>> {
246        self.inner.list_tools().await
247    }
248
249    /// Calls a tool on the upstream server.
250    pub async fn call_tool(
251        &self,
252        params: &super::tool::CallToolRequestParams,
253    ) -> Result<super::tool::CallToolResult, super::Error> {
254        self.inner.call_tool(params).await
255    }
256
257    /// Calls a tool and converts the result into a [`ToolMessage`].
258    pub async fn call_tool_as_message(
259        &self,
260        params: &super::tool::CallToolRequestParams,
261        tool_call_id: String,
262    ) -> Result<crate::agent::completions::message::ToolMessage, super::Error>
263    {
264        self.inner.call_tool_as_message(params, tool_call_id).await
265    }
266
267    /// Returns all resources from the upstream server.
268    pub async fn list_resources(
269        &self,
270    ) -> Result<Arc<Vec<super::resource::Resource>>, Arc<super::Error>> {
271        self.inner.list_resources().await
272    }
273
274    /// Reads a resource from the upstream server.
275    pub async fn read_resource(
276        &self,
277        uri: &str,
278    ) -> Result<super::resource::ReadResourceResult, super::Error> {
279        self.inner.read_resource(uri).await
280    }
281
282    /// Register a callback to fire whenever the upstream emits
283    /// `notifications/tools/list_changed`.
284    ///
285    /// **Timing:** the callback runs *after* the tool cache's write lock
286    /// is acquired but *before* the network paginate that replaces it.
287    /// That means readers blocked on the read lock won't return until the
288    /// new list is in place, and the callback observes the moment the
289    /// staleness window opens. The proxy uses this to emit its own
290    /// `notifications/tools/list_changed` to downstream clients at the
291    /// right instant.
292    ///
293    /// Replaces any previously-registered tools-list-changed callback.
294    /// All clones of this `Connection` share the same callback slot.
295    pub fn set_on_tools_list_changed<F>(&self, callback: F)
296    where
297        F: Fn() + Send + Sync + 'static,
298    {
299        self.inner.on_tools_list_changed.set(Arc::new(callback));
300    }
301
302    /// Register a callback to fire whenever the upstream emits
303    /// `notifications/resources/list_changed`. Same timing contract as
304    /// [`Connection::set_on_tools_list_changed`].
305    ///
306    /// Replaces any previously-registered resources-list-changed callback.
307    /// All clones of this `Connection` share the same callback slot.
308    pub fn set_on_resources_list_changed<F>(&self, callback: F)
309    where
310        F: Fn() + Send + Sync + 'static,
311    {
312        self.inner.on_resources_list_changed.set(Arc::new(callback));
313    }
314
315    /// Atomically replace the connection's [`ConnectionInner::extra_headers`]
316    /// bag. Every subsequent outbound HTTP request from this connection
317    /// stamps the new map AFTER `headers`, with `HeaderMap::insert`
318    /// REPLACE semantics — keys in `extras` override the same key in
319    /// the per-URL `headers` bag set at `Client::connect`. Caller
320    /// supplies the FULL replacement map; missing keys are dropped
321    /// (no merge).
322    ///
323    /// Used by the proxy to inject session-global headers
324    /// (`X-OBJECTIVEAI-RESPONSE-ID`, `X-OBJECTIVEAI-RESPONSE-IDS`)
325    /// that re-set on every inbound `initialize`, without re-dialing
326    /// the upstream.
327    pub async fn set_extra_headers(
328        &self,
329        extras: IndexMap<String, String>,
330    ) {
331        *self.inner.extra_headers.write().await = extras;
332    }
333}
334
335/// The actual connection state. Behind an `Arc` inside [`Connection`].
336///
337/// Fields are public for read-only access (callers reach them via
338/// `Connection`'s `Deref`), but every method on this type is private —
339/// the public surface lives on [`Connection`] and delegates through.
340#[derive(Debug)]
341pub struct ConnectionInner {
342    pub http_client: reqwest::Client,
343    pub url: String,
344    pub session_id: String,
345    /// All HTTP headers stamped on every POST / GET this connection
346    /// makes — the same merged map (defaults + caller overrides) the
347    /// `Client` built once during connect. `Mcp-Session-Id`,
348    /// `Content-Type`, and `Accept` are still set by the request
349    /// builders and override anything in `headers`.
350    pub headers: IndexMap<String, String>,
351    /// Mutable per-request override layer stamped AFTER `headers` on
352    /// every outbound HTTP request. The request-builder uses
353    /// `reqwest::header::HeaderMap::insert` semantics so any key
354    /// present in `extra_headers` REPLACES the same key in `headers`.
355    /// Used by the proxy to inject session-global headers
356    /// (`X-OBJECTIVEAI-RESPONSE-ID` etc.) that override per-URL
357    /// values without re-dialing. Empty by default; set via
358    /// [`Connection::set_extra_headers`].
359    pub extra_headers: RwLock<IndexMap<String, String>>,
360
361    pub backoff_current_interval: Duration,
362    pub backoff_initial_interval: Duration,
363    pub backoff_randomization_factor: f64,
364    pub backoff_multiplier: f64,
365    pub backoff_max_interval: Duration,
366    pub backoff_max_elapsed_time: Duration,
367    /// Per-RPC timeout; `None` = no timeout (wait forever).
368    pub call_timeout: Option<Duration>,
369
370    /// The server's capabilities and info from the initialize response.
371    pub initialize_result: super::initialize_result::InitializeResult,
372
373    /// Auto-incrementing request ID (starts at 2; 1 was used for initialize).
374    next_id: AtomicU64,
375
376    /// All tools from the server, populated by background pagination.
377    ///
378    /// `None` = cache cleared — either pre-populate (between
379    /// [`Self::new`] and the first `refresh_tools_signaling`) or
380    /// post-drop (the listener empties this the moment its SSE
381    /// stream ends so `list_tools` will re-paginate against the
382    /// upstream rather than return stale state). `Some(_)` = last
383    /// known result, `Ok` or `Err`.
384    tools:
385        RwLock<Option<Result<Arc<Vec<super::tool::Tool>>, Arc<super::Error>>>>,
386    /// All resources from the server, populated by background pagination.
387    /// Same `None`/`Some` semantics as [`Self::tools`].
388    resources: RwLock<
389        Option<Result<Arc<Vec<super::resource::Resource>>, Arc<super::Error>>>,
390    >,
391
392    /// Cancellation token for the long-lived `listen_for_list_changes`
393    /// task. The listener selects this against every blocking await
394    /// (read, reconnect-send, backoff-sleep) and returns the instant it
395    /// fires.
396    ///
397    /// Held inside the connection as a [`DropGuard`] so that the moment
398    /// the last `Arc<ConnectionInner>` clone is dropped — i.e. the
399    /// moment no external `Connection` handle remains — `Drop` runs on
400    /// the guard, the token cancels, and the listener task tears down.
401    /// The listener itself holds a sibling `CancellationToken` (clone),
402    /// not the guard, so its task does not extend the connection's
403    /// lifetime.
404    ///
405    /// Wrapped in `Mutex<Option<_>>` so explicit teardown paths
406    /// ([`Connection::delete`]) can drop the guard in place — firing
407    /// the cancel token *before* the surrounding `Arc<ConnectionInner>`
408    /// goes away. Regular drop still works: `Mutex<Option<DropGuard>>`
409    /// drops its inner `DropGuard` automatically when the mutex itself
410    /// drops, so the listener is still cancelled on the last `Arc` drop.
411    _listener_cancel_guard: std::sync::Mutex<Option<DropGuard>>,
412
413    /// Optional callback fired *after* the listener has refreshed the
414    /// tool cache in response to an upstream `notifications/tools/list_changed`.
415    /// Set via [`Connection::set_on_tools_list_changed`].
416    on_tools_list_changed: CallbackSlot,
417
418    /// Optional callback fired *after* the listener has refreshed the
419    /// resource cache in response to an upstream
420    /// `notifications/resources/list_changed`.
421    /// Set via [`Connection::set_on_resources_list_changed`].
422    on_resources_list_changed: CallbackSlot,
423}
424
425impl ConnectionInner {
426    /// Creates a minimal connection for unit testing. Declares both
427    /// `tools` and `resources` capabilities with `list_changed:
428    /// Some(true)` so callers exercise the present-cap +
429    /// list_changed-enabled paths in `list_*`, `refresh_*`, and
430    /// `subscribe_*`. For other capability shapes use
431    /// `new_for_test_with_caps`.
432    #[cfg(test)]
433    fn new_for_test(name: String, url: String) -> Arc<Self> {
434        Self::new_for_test_with_caps(
435            name,
436            url,
437            super::initialize_result::ServerCapabilities {
438                experimental: None,
439                logging: None,
440                completions: None,
441                prompts: None,
442                resources: Some(
443                    super::initialize_result::ResourcesCapability {
444                        subscribe: None,
445                        list_changed: Some(true),
446                    },
447                ),
448                tools: Some(super::initialize_result::ToolsCapability {
449                    list_changed: Some(true),
450                }),
451                tasks: None,
452            },
453        )
454    }
455
456    /// Creates a minimal connection for unit testing with an explicit
457    /// `ServerCapabilities`. Used by the capability-gating tests to
458    /// drive each gate's absent-cap branch.
459    #[cfg(test)]
460    fn new_for_test_with_caps(
461        name: String,
462        url: String,
463        capabilities: super::initialize_result::ServerCapabilities,
464    ) -> Arc<Self> {
465        Arc::new(Self {
466            http_client: reqwest::Client::new(),
467            url,
468            session_id: String::new(),
469            headers: IndexMap::new(),
470            extra_headers: RwLock::new(IndexMap::new()),
471            backoff_current_interval: Duration::from_millis(500),
472            backoff_initial_interval: Duration::from_millis(500),
473            backoff_randomization_factor: 0.5,
474            backoff_multiplier: 1.5,
475            backoff_max_interval: Duration::from_secs(60),
476            backoff_max_elapsed_time: Duration::from_secs(900),
477            call_timeout: Some(Duration::from_secs(30)),
478            initialize_result: super::initialize_result::InitializeResult {
479                protocol_version: "2025-03-26".into(),
480                capabilities,
481                server_info: super::initialize_result::Implementation {
482                    name,
483                    title: None,
484                    version: "0.0.0".into(),
485                    website_url: None,
486                    description: None,
487                    icons: None,
488                },
489                instructions: None,
490                _meta: None,
491            },
492            next_id: AtomicU64::new(2),
493            // Test connection has no listener and never refreshes; seed
494            // with an empty Ok so `list_tools` doesn't try to paginate.
495            tools: RwLock::new(Some(Ok(Arc::new(Vec::new())))),
496            resources: RwLock::new(Some(Ok(Arc::new(Vec::new())))),
497            _listener_cancel_guard: std::sync::Mutex::new(None),
498            on_tools_list_changed: CallbackSlot::new(),
499            on_resources_list_changed: CallbackSlot::new(),
500        })
501    }
502
503    /// Creates a new connection and spawns background tasks to paginate
504    /// all tools and resources. Called internally by
505    /// [`Client::connect`](super::Client::connect) (via [`Connection::new`]).
506    ///
507    /// `initial_sse_lines`, if `Some`, is a pre-opened SSE line reader
508    /// that the list-changed listener will read from immediately on its
509    /// first iteration, instead of opening its own GET `/`. The caller
510    /// is responsible for arranging for one of these to exist whenever
511    /// the upstream advertises `tools.list_changed` or
512    /// `resources.list_changed` — see
513    /// [`Client::connect`](super::Client::connect).
514    async fn new(
515        http_client: reqwest::Client,
516        url: String,
517        session_id: String,
518        headers: IndexMap<String, String>,
519        backoff_current_interval: Duration,
520        backoff_initial_interval: Duration,
521        backoff_randomization_factor: f64,
522        backoff_multiplier: f64,
523        backoff_max_interval: Duration,
524        backoff_max_elapsed_time: Duration,
525        call_timeout: Option<Duration>,
526        initialize_result: super::initialize_result::InitializeResult,
527        initial_sse_lines: Option<super::LinesStream>,
528    ) -> Arc<Self> {
529        // Cancel-the-listener machinery: store the DropGuard inside the
530        // inner so the cancellation fires deterministically when the
531        // last external `Arc<ConnectionInner>` clone drops. Hand the
532        // listener task a sibling clone (no guard) — that way the
533        // listener task's lifetime does not extend the connection.
534        let listener_cancel = CancellationToken::new();
535        let listener_cancel_for_task = listener_cancel.clone();
536        let conn = Arc::new(Self {
537            http_client,
538            url,
539            session_id,
540            headers,
541            extra_headers: RwLock::new(IndexMap::new()),
542            backoff_current_interval,
543            backoff_initial_interval,
544            backoff_randomization_factor,
545            backoff_multiplier,
546            backoff_max_interval,
547            backoff_max_elapsed_time,
548            call_timeout,
549            initialize_result,
550            next_id: AtomicU64::new(2),
551            // Start empty; `refresh_tools_signaling` below installs
552            // `Some(_)` before `new` returns (the lock-handoff oneshot
553            // gates the return on the writer holding the lock).
554            tools: RwLock::new(None),
555            resources: RwLock::new(None),
556            _listener_cancel_guard: std::sync::Mutex::new(Some(
557                listener_cancel.drop_guard(),
558            )),
559            on_tools_list_changed: CallbackSlot::new(),
560            on_resources_list_changed: CallbackSlot::new(),
561        });
562
563        // Spawn background tool lister if the server supports tools.
564        //
565        // We don't return until the spawned task has acquired the write
566        // lock. Otherwise a caller that immediately reads `list_tools()`
567        // could race the writer — `tokio::spawn` only queues the task,
568        // and a fast reader can acquire the read lock before the writer
569        // has run its first instruction. The reader would then see the
570        // initial empty `Vec` and return that, even though a full
571        // populate is in flight.
572        //
573        // The `RwLockWriteGuard` itself isn't `Send`-friendly enough to
574        // pass back, so we use a oneshot to signal "I'm holding the
575        // lock now"; once we receive that, the cache is exclusively
576        // owned by the writer and any subsequent `read().await` from
577        // the caller is guaranteed to wait for the populate to finish.
578        if conn.initialize_result.capabilities.tools.is_some() {
579            let conn = Arc::clone(&conn);
580            let (lock_held_tx, lock_held_rx) = tokio::sync::oneshot::channel();
581            tokio::spawn(async move {
582                conn.refresh_tools_signaling(lock_held_tx, None).await;
583            });
584            // Wait for the writer to hold the lock before returning.
585            let _ = lock_held_rx.await;
586        }
587
588        // Spawn background resource lister if the server supports
589        // resources. Same lock-handoff contract as tools above.
590        if conn.initialize_result.capabilities.resources.is_some() {
591            let conn = Arc::clone(&conn);
592            let (lock_held_tx, lock_held_rx) = tokio::sync::oneshot::channel();
593            tokio::spawn(async move {
594                conn.refresh_resources_signaling(lock_held_tx, None).await;
595            });
596            let _ = lock_held_rx.await;
597        }
598
599        // Spawn the list-changed listener iff the caller handed us a
600        // pre-opened SSE stream. The connection is naive about
601        // `tools.list_changed` / `resources.list_changed` capabilities —
602        // [`Client::connect`](super::Client::connect) translates them
603        // into "did or didn't open a stream for us." If we get a stream,
604        // we listen on it; if we don't, there's nothing to listen for.
605        if let Some(initial_lines) = initial_sse_lines {
606            // Hand the listener a `Weak` so the spawned task itself does
607            // not keep the connection alive. `listener_cancel_for_task`
608            // is a sibling clone of the connection's own
609            // `_listener_cancel_guard` token — when the last external
610            // `Arc<ConnectionInner>` clone is dropped, the inner's Drop
611            // releases the guard and the listener wakes from any
612            // pending await (read, send, sleep) and exits immediately.
613            let weak = Arc::downgrade(&conn);
614            tokio::spawn(async move {
615                Self::listen_for_list_changes(
616                    weak,
617                    listener_cancel_for_task,
618                    initial_lines,
619                )
620                .await;
621            });
622        }
623
624        conn
625    }
626
627    /// Server declared a `tools` capability in its `InitializeResult`.
628    /// Gates `list_tools`, `call_tool`, `refresh_tools`. When `false` the
629    /// upstream cannot service `tools/*` RPCs at all and any attempt would
630    /// either hang in idempotent backoff (`tools/list`) or fail with
631    /// `SessionExpired` (`tools/call`).
632    fn has_tools_cap(&self) -> bool {
633        self.initialize_result.capabilities.tools.is_some()
634    }
635
636    /// Server declared a `resources` capability in its `InitializeResult`.
637    /// Gates `list_resources`, `read_resource`, `refresh_resources`, and
638    /// the post-`call_tool` ResourceLink resolution. Same hang-or-fail
639    /// shape as `has_tools_cap` when absent.
640    fn has_resources_cap(&self) -> bool {
641        self.initialize_result.capabilities.resources.is_some()
642    }
643
644    /// Creates an exponential backoff configuration from the connection's fields.
645    fn backoff(&self) -> backoff::ExponentialBackoff {
646        backoff::ExponentialBackoff {
647            current_interval: self.backoff_current_interval,
648            initial_interval: self.backoff_initial_interval,
649            randomization_factor: self.backoff_randomization_factor,
650            multiplier: self.backoff_multiplier,
651            max_interval: self.backoff_max_interval,
652            start_time: std::time::Instant::now(),
653            max_elapsed_time: Some(self.backoff_max_elapsed_time),
654            clock: backoff::SystemClock::default(),
655        }
656    }
657
658    /// Builds a POST request with all required headers and the call
659    /// timeout (when one is configured).
660    async fn post(&self) -> reqwest::RequestBuilder {
661        super::apply_timeout(self.http_client.post(&self.url), self.call_timeout)
662            .headers(
663                self.build_request_headers(
664                    Some("application/json"),
665                    Some("application/json, text/event-stream"),
666                )
667                .await,
668            )
669    }
670
671    /// Build the `HeaderMap` stamped on every outbound request. Order
672    /// of insertion drives override semantics — `HeaderMap::insert`
673    /// REPLACES existing values for the same key:
674    ///
675    /// 1. Content-Type / Accept (when supplied by the caller).
676    /// 2. `self.headers` (the per-URL bag set at `Client::connect`).
677    /// 3. `self.extra_headers` (the mutable, session-global overrides
678    ///    — proxies use this for `X-OBJECTIVEAI-RESPONSE-ID` etc).
679    /// 4. `Mcp-Session-Id` (the connection's own session id, always
680    ///    last so it can never be shadowed).
681    async fn build_request_headers(
682        &self,
683        content_type: Option<&str>,
684        accept: Option<&str>,
685    ) -> reqwest::header::HeaderMap {
686        use reqwest::header::{
687            ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue,
688        };
689        let mut hmap = HeaderMap::new();
690        if let Some(ct) = content_type {
691            if let Ok(hv) = HeaderValue::from_str(ct) {
692                hmap.insert(CONTENT_TYPE, hv);
693            }
694        }
695        if let Some(a) = accept {
696            if let Ok(hv) = HeaderValue::from_str(a) {
697                hmap.insert(ACCEPT, hv);
698            }
699        }
700        for (k, v) in &self.headers {
701            if let (Ok(hn), Ok(hv)) = (
702                HeaderName::try_from(k.as_str()),
703                HeaderValue::from_str(v),
704            ) {
705                hmap.insert(hn, hv);
706            }
707        }
708        let extras = self.extra_headers.read().await;
709        for (k, v) in extras.iter() {
710            if let (Ok(hn), Ok(hv)) = (
711                HeaderName::try_from(k.as_str()),
712                HeaderValue::from_str(v),
713            ) {
714                hmap.insert(hn, hv);
715            }
716        }
717        drop(extras);
718        if let Ok(hv) = HeaderValue::from_str(&self.session_id) {
719            hmap.insert(HeaderName::from_static("mcp-session-id"), hv);
720        }
721        hmap
722    }
723
724    /// Sends a JSON-RPC request, retrying transient errors when
725    /// `idempotent` is `true`.
726    ///
727    /// Idempotent methods (`tools/list`, `resources/list`,
728    /// `resources/read`, etc.) retry every transient error — network,
729    /// HTTP status, malformed body, JSON-RPC error, session expiration —
730    /// until the backoff's `max_elapsed_time` is exceeded.
731    ///
732    /// Non-idempotent methods (`tools/call`) make exactly one attempt.
733    /// Retrying a `tools/call` is unsafe: a tool may have mutated remote
734    /// state during the first attempt before the response was lost, and
735    /// re-firing the call would mutate state again. Each retry of
736    /// `AppendTask` advances `state.tasks.len()` an extra step, so the
737    /// agent sees a different return value than expected and the
738    /// pid-derived mock seed at the next step diverges. See
739    /// `objectiveai-api/src/agent/completions/client.rs` (sequential
740    /// dispatch) and `mock/client.rs::mock.seed_derive` for the
741    /// downstream consequence.
742    async fn rpc<P: serde::Serialize, R: serde::de::DeserializeOwned>(
743        &self,
744        method: &str,
745        params: &P,
746        idempotent: bool,
747    ) -> Result<R, super::Error> {
748        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
749        let body = serde_json::json!({
750            "jsonrpc": "2.0",
751            "id": id,
752            "method": method,
753            "params": params,
754        });
755
756        let attempt_one = || async {
757            let url = self.url.clone();
758            let response =
759                self.post().await.json(&body).send().await.map_err(|source| {
760                    backoff::Error::transient(super::Error::Request {
761                        url: url.clone(),
762                        source,
763                    })
764                })?;
765
766            if response.status() == reqwest::StatusCode::NOT_FOUND {
767                return Err(backoff::Error::transient(
768                    super::Error::SessionExpired { url: url.clone() },
769                ));
770            }
771            if !response.status().is_success() {
772                let code = response.status();
773                let body = response.text().await.unwrap_or_default();
774                return Err(backoff::Error::transient(
775                    super::Error::BadStatus {
776                        url: url.clone(),
777                        code,
778                        body,
779                    },
780                ));
781            }
782
783            let rpc_response: super::JsonRpcResponse<R> =
784                super::parse_streamable_http_response(&url, response)
785                    .await
786                    .map_err(backoff::Error::transient)?;
787
788            match rpc_response {
789                super::JsonRpcResponse::Success { result, .. } => Ok(result),
790                super::JsonRpcResponse::Error { error, .. } => {
791                    Err(backoff::Error::transient(super::Error::JsonRpc {
792                        url: url.clone(),
793                        code: error.code,
794                        message: error.message,
795                        data: error.data,
796                    }))
797                }
798            }
799        };
800
801        if idempotent {
802            backoff::future::retry(self.backoff(), attempt_one).await
803        } else {
804            attempt_one().await.map_err(|e| match e {
805                backoff::Error::Permanent(err)
806                | backoff::Error::Transient { err, .. } => err,
807            })
808        }
809    }
810
811    /// Returns a key identifying this connection for tool namespacing.
812    fn tool_key(&self) -> String {
813        format!("{}-{}", self.initialize_result.server_info.name, self.url)
814    }
815
816    /// Returns the session ID for this connection.
817    fn session_id(&self) -> &str {
818        &self.session_id
819    }
820
821    /// Sends a `tools/list` RPC call for a single page.
822    async fn rpc_list_tools(
823        &self,
824        cursor: Option<&str>,
825    ) -> Result<super::tool::ListToolsResult, super::Error> {
826        self.rpc(
827            "tools/list",
828            &super::tool::ListToolsRequest {
829                cursor: cursor.map(String::from),
830            },
831            true,
832        )
833        .await
834    }
835
836    /// Returns all tools from the server.
837    ///
838    /// Blocks until background pagination completes, then returns a
839    /// cheap `Arc` clone of the result. If the cache is currently
840    /// empty (e.g. because the listener detected its SSE stream drop
841    /// and cleared it) this paginates inline against the upstream —
842    /// the caller gets fresh data on the happy path, or the live
843    /// upstream error if the connection is genuinely down, rather
844    /// than stale pre-drop tools.
845    async fn list_tools(
846        &self,
847    ) -> Result<Arc<Vec<super::tool::Tool>>, Arc<super::Error>> {
848        if !self.has_tools_cap() {
849            return Ok(Arc::new(Vec::new()));
850        }
851        if let Some(cached) = self.tools.read().await.as_ref() {
852            return cached.clone();
853        }
854        // Cache cleared; refresh inline. Concurrent callers may each
855        // refresh — wasteful, but the proxy fans out across distinct
856        // upstreams so a single Connection rarely sees concurrent
857        // `list_tools` calls.
858        self.refresh_tools(None).await;
859        self.tools
860            .read()
861            .await
862            .as_ref()
863            .expect("refresh_tools installs Some")
864            .clone()
865    }
866
867    /// Calls a tool on the MCP server. The returned
868    /// `CallToolResult.content` is **fully resolved**: every
869    /// `ContentBlock::ResourceLink { uri }` whose URI appears in
870    /// `list_resources` has already been replaced by one or more
871    /// `ContentBlock::EmbeddedResource` blocks carrying the fetched
872    /// contents (via `read_resource`). Unknown-URI links pass through
873    /// untouched — the upstream server may have its own out-of-band
874    /// resolution path the caller should preserve.
875    ///
876    /// Resolving inside `call_tool` (rather than downstream in
877    /// `call_tool_as_message`) means every consumer of the result
878    /// sees `EmbeddedResource` shapes uniformly; the stateless
879    /// `From<ContentBlock>` impl is then enough to convert the whole
880    /// result to `RichContent` with no further connection work.
881    async fn call_tool(
882        &self,
883        params: &super::tool::CallToolRequestParams,
884    ) -> Result<super::tool::CallToolResult, super::Error> {
885        if !self.has_tools_cap() {
886            return Err(super::Error::UnsupportedCapability {
887                capability: "tools",
888            });
889        }
890        let mut result: super::tool::CallToolResult =
891            self.rpc("tools/call", params, false).await?;
892
893        // Build the known-resource URI set for ResourceLink
894        // resolution. `list_resources` failure → empty set (same
895        // safe fallback the resolution path used previously).
896        let known_uris: std::collections::HashSet<String> =
897            match self.list_resources().await {
898                Ok(rs) => rs.iter().map(|r| r.uri.clone()).collect(),
899                Err(_) => std::collections::HashSet::new(),
900            };
901
902        // Walk the blocks, replacing each resolvable ResourceLink
903        // with one EmbeddedResource per returned ResourceContentsUnion.
904        // Everything else (Text, Image, Audio, EmbeddedResource,
905        // unknown-URI ResourceLinks) passes through.
906        let mut resolved: Vec<super::tool::ContentBlock> =
907            Vec::with_capacity(result.content.len());
908        for block in std::mem::take(&mut result.content) {
909            match block {
910                super::tool::ContentBlock::ResourceLink(link)
911                    if known_uris.contains(&link.uri) =>
912                {
913                    let read = self.read_resource(&link.uri).await?;
914                    for contents in read.contents {
915                        resolved.push(
916                            super::tool::ContentBlock::EmbeddedResource(
917                                super::tool::EmbeddedResource {
918                                    resource: contents,
919                                    // ResourceLink's annotations
920                                    // don't have a perfect home on
921                                    // EmbeddedResource — both fields
922                                    // exist but they describe different
923                                    // shapes (the link vs the inlined
924                                    // contents). Drop them on the way
925                                    // in; the EmbeddedResource is now
926                                    // a fresh authoritative block.
927                                    annotations: None,
928                                    _meta: None,
929                                },
930                            ),
931                        );
932                    }
933                }
934                other => resolved.push(other),
935            }
936        }
937        result.content = resolved;
938        Ok(result)
939    }
940
941    /// Calls a tool and converts the (already-resolved) result into a
942    /// [`ToolMessage`]. Resource resolution happens inside
943    /// [`Self::call_tool`] — by the time we get the blocks here every
944    /// resolvable `ResourceLink` has already been replaced with an
945    /// `EmbeddedResource`, so the conversion is a pure stateless
946    /// element-wise map through [`From<ContentBlock> for
947    /// RichContentPart`](crate::agent::completions::message::RichContentPart).
948    ///
949    /// Content-block mapping (handled by the `From` impl):
950    /// - `text` → text part
951    /// - `image` → image_url part (data URL)
952    /// - `audio` → input_audio part
953    /// - `embedded_resource` (text) → text part
954    /// - `embedded_resource` (blob, image mime) → image_url part
955    /// - `embedded_resource` (blob, audio mime) → input_audio part
956    /// - `embedded_resource` (blob, video mime) → input_video part
957    /// - `embedded_resource` (blob, other mime) → file part
958    /// - `resource_link` (unknown URI) → JSON-text fallback
959    ///   (resolvable URIs were already resolved upstream)
960    async fn call_tool_as_message(
961        &self,
962        params: &super::tool::CallToolRequestParams,
963        tool_call_id: String,
964    ) -> Result<crate::agent::completions::message::ToolMessage, super::Error>
965    {
966        use crate::agent::completions::message::{
967            RichContentPart, ToolMessage, ToolResponseMetadata,
968        };
969
970        let result = self.call_tool(params).await?;
971
972        let parts: Vec<RichContentPart> =
973            result.content.into_iter().map(Into::into).collect();
974
975        // Lossy-decode the MCP `_meta` extension bag into our typed
976        // `ToolResponseMetadata`. Unknown keys (set by non-objectiveai
977        // upstreams) are silently dropped. Decoding failure leaves
978        // metadata as `None`.
979        let metadata = result._meta.as_ref().and_then(|m| {
980            serde_json::from_value::<ToolResponseMetadata>(
981                serde_json::to_value(m).ok()?,
982            )
983            .ok()
984        });
985
986        Ok(ToolMessage {
987            content: parts.into(),
988            tool_call_id,
989            metadata,
990        })
991    }
992
993    /// Sends a `resources/list` RPC call for a single page.
994    async fn rpc_list_resources(
995        &self,
996        cursor: Option<&str>,
997    ) -> Result<super::resource::ListResourcesResult, super::Error> {
998        self.rpc(
999            "resources/list",
1000            &super::resource::ListResourcesRequest {
1001                cursor: cursor.map(String::from),
1002            },
1003            true,
1004        )
1005        .await
1006    }
1007
1008    /// Returns all resources from the server.
1009    ///
1010    /// Same cache-or-refresh semantics as [`Self::list_tools`]: a
1011    /// cleared cache (post-drop or pre-populate) triggers an inline
1012    /// paginate against the upstream.
1013    async fn list_resources(
1014        &self,
1015    ) -> Result<Arc<Vec<super::resource::Resource>>, Arc<super::Error>> {
1016        if !self.has_resources_cap() {
1017            return Ok(Arc::new(Vec::new()));
1018        }
1019        if let Some(cached) = self.resources.read().await.as_ref() {
1020            return cached.clone();
1021        }
1022        self.refresh_resources(None).await;
1023        self.resources
1024            .read()
1025            .await
1026            .as_ref()
1027            .expect("refresh_resources installs Some")
1028            .clone()
1029    }
1030
1031    /// Reads a resource from the MCP server.
1032    async fn read_resource(
1033        &self,
1034        uri: &str,
1035    ) -> Result<super::resource::ReadResourceResult, super::Error> {
1036        if !self.has_resources_cap() {
1037            return Err(super::Error::UnsupportedCapability {
1038                capability: "resources",
1039            });
1040        }
1041        self.rpc(
1042            "resources/read",
1043            &super::resource::ReadResourceRequestParams {
1044                uri: uri.to_string(),
1045            },
1046            true,
1047        )
1048        .await
1049    }
1050
1051    /// Re-fetches all tools from the server, replacing the cached list.
1052    ///
1053    /// Optionally fires `on_change` *after* the write lock is acquired but
1054    /// *before* the network paginate begins, so the callback observes the
1055    /// "list change is in flight" edge — readers blocked on the read lock
1056    /// won't return until the new list lands. The proxy uses this to
1057    /// re-emit `notifications/tools/list_changed` to its downstream client
1058    /// at the moment the staleness window opens.
1059    async fn refresh_tools(&self, on_change: Option<ListChangedCallback>) {
1060        if !self.has_tools_cap() {
1061            // No tools capability — install an empty Vec so the cache
1062            // contract holds (`list_tools`'s `.expect("refresh_tools
1063            // installs Some")` etc.) and return without paginating or
1064            // signalling. No `notify_waiters` and no `on_change` —
1065            // nothing real changed.
1066            let mut guard = self.tools.write().await;
1067            *guard = Some(Ok(Arc::new(Vec::new())));
1068            return;
1069        }
1070        // Listener-driven refresh. Visibility contract: any caller
1071        // that issues `list_tools()` after a `tools/list_changed`
1072        // notification has been observed must see the post-swap
1073        // value, not stale data — so the write lock has to gate
1074        // readers across the upstream paginate.
1075        //
1076        // Performance contract: don't serialise paginate *behind*
1077        // lock-acquisition latency. We start `tools.write()` and the
1078        // upstream paginate **concurrently** with `tokio::join!`. The
1079        // write-lock acquire blocks new `list_tools()` readers
1080        // immediately (preserving visibility) and runs in parallel
1081        // with whatever drain time the in-flight readers need; the
1082        // paginate runs alongside. Total wall-clock is
1083        // `max(drain_time, paginate_time)` instead of the sum.
1084        //
1085        // `on_change` fires under the write guard, *after* `*guard =
1086        // result`, so anyone the callback wakes queues on the read lock,
1087        // waits for the guard to drop, and observes the post-swap state.
1088        let (mut guard, result) =
1089            tokio::join!(self.tools.write(), self.paginate_tools(),);
1090        *guard = Some(result);
1091        if let Some(cb) = on_change {
1092            cb();
1093        }
1094    }
1095
1096    /// Page-by-page fetch of the upstream tool list, no locks held.
1097    /// Shared between the `_signaling` (initial-populate, holds lock
1098    /// for the original "block fast readers" contract) and `refresh_*`
1099    /// (listener-driven, lock-only-around-install) variants.
1100    async fn paginate_tools(
1101        &self,
1102    ) -> Result<Arc<Vec<super::tool::Tool>>, Arc<super::Error>> {
1103        let mut all_tools = Vec::new();
1104        let mut cursor: Option<String> = None;
1105        loop {
1106            match self.rpc_list_tools(cursor.as_deref()).await {
1107                Ok(page) => {
1108                    all_tools.extend(page.tools);
1109                    cursor = page.next_cursor;
1110                    if cursor.is_none() {
1111                        return Ok(Arc::new(all_tools));
1112                    }
1113                }
1114                Err(e) => return Err(Arc::new(e)),
1115            }
1116        }
1117    }
1118
1119    /// Same as [`Self::refresh_tools`] but fires `lock_held` once the
1120    /// write lock has been acquired so the caller can synchronise on
1121    /// "writer is in possession of the cache" before returning. Used by
1122    /// `ConnectionInner::new` to prevent a fast reader from acquiring
1123    /// the read lock before this writer has even started.
1124    ///
1125    /// **Caller invariant:** the spawn site in `ConnectionInner::new`
1126    /// must gate this call on `capabilities.tools.is_some()`. This
1127    /// method assumes the tools capability is present and issues
1128    /// `tools/list` RPCs unconditionally — running it against a
1129    /// no-tools server triggers the 15-min idempotent-backoff storm.
1130    async fn refresh_tools_signaling(
1131        &self,
1132        lock_held: tokio::sync::oneshot::Sender<()>,
1133        on_change: Option<ListChangedCallback>,
1134    ) {
1135        let mut guard = self.tools.write().await;
1136        // Signal `lock_held` while we hold the write lock and *before*
1137        // installing the new list, so the `on_change` callback observes
1138        // the "list change in flight" edge — its readers queue behind
1139        // this write guard and always see the post-swap state.
1140        let _ = lock_held.send(());
1141        if let Some(cb) = on_change {
1142            cb();
1143        }
1144        let mut all_tools = Vec::new();
1145        let mut cursor: Option<String> = None;
1146        let result = loop {
1147            match self.rpc_list_tools(cursor.as_deref()).await {
1148                Ok(page) => {
1149                    all_tools.extend(page.tools);
1150                    cursor = page.next_cursor;
1151                    if cursor.is_none() {
1152                        break Ok(Arc::new(all_tools));
1153                    }
1154                }
1155                Err(e) => break Err(Arc::new(e)),
1156            }
1157        };
1158        *guard = Some(result);
1159    }
1160
1161    /// Re-fetches all resources from the server, replacing the cached list.
1162    /// See [`ConnectionInner::refresh_tools`] for the callback timing
1163    /// contract.
1164    async fn refresh_resources(&self, on_change: Option<ListChangedCallback>) {
1165        if !self.has_resources_cap() {
1166            // Symmetric to `refresh_tools` — see that gate.
1167            let mut guard = self.resources.write().await;
1168            *guard = Some(Ok(Arc::new(Vec::new())));
1169            return;
1170        }
1171        // Same paginate-while-acquiring-the-write-lock pattern as
1172        // `refresh_tools` — see that comment for the visibility +
1173        // performance rationale.
1174        let (mut guard, result) =
1175            tokio::join!(self.resources.write(), self.paginate_resources(),);
1176        *guard = Some(result);
1177        if let Some(cb) = on_change {
1178            cb();
1179        }
1180    }
1181
1182    async fn paginate_resources(
1183        &self,
1184    ) -> Result<Arc<Vec<super::resource::Resource>>, Arc<super::Error>> {
1185        let mut all_resources = Vec::new();
1186        let mut cursor: Option<String> = None;
1187        loop {
1188            match self.rpc_list_resources(cursor.as_deref()).await {
1189                Ok(page) => {
1190                    all_resources.extend(page.resources);
1191                    cursor = page.next_cursor;
1192                    if cursor.is_none() {
1193                        return Ok(Arc::new(all_resources));
1194                    }
1195                }
1196                Err(e) => return Err(Arc::new(e)),
1197            }
1198        }
1199    }
1200
1201    /// Resource counterpart of [`Self::refresh_tools_signaling`]. The
1202    /// same spawn-site-gate invariant applies: the caller must gate
1203    /// the spawn on `capabilities.resources.is_some()`.
1204    async fn refresh_resources_signaling(
1205        &self,
1206        lock_held: tokio::sync::oneshot::Sender<()>,
1207        on_change: Option<ListChangedCallback>,
1208    ) {
1209        let mut guard = self.resources.write().await;
1210        // See `refresh_tools_signaling` — signal `lock_held` under the
1211        // write lock, before install, so the `on_change` callback's
1212        // readers see the post-swap state.
1213        let _ = lock_held.send(());
1214        if let Some(cb) = on_change {
1215            cb();
1216        }
1217        let mut all_resources = Vec::new();
1218        let mut cursor: Option<String> = None;
1219        let result = loop {
1220            match self.rpc_list_resources(cursor.as_deref()).await {
1221                Ok(page) => {
1222                    all_resources.extend(page.resources);
1223                    cursor = page.next_cursor;
1224                    if cursor.is_none() {
1225                        break Ok(Arc::new(all_resources));
1226                    }
1227                }
1228                Err(e) => break Err(Arc::new(e)),
1229            }
1230        };
1231        *guard = Some(result);
1232    }
1233
1234    /// Builds a GET request to the MCP endpoint for receiving server
1235    /// notifications via SSE.
1236    async fn get(&self) -> reqwest::RequestBuilder {
1237        self.http_client
1238            .get(&self.url)
1239            .headers(
1240                self.build_request_headers(None, Some("text/event-stream"))
1241                    .await,
1242            )
1243    }
1244
1245    /// Listens for `notifications/tools/list_changed` and
1246    /// `notifications/resources/list_changed` on an SSE stream. On each
1247    /// notification, write-locks and re-fetches the full list.
1248    ///
1249    /// `initial_lines` is the pre-opened SSE line reader handed in by
1250    /// [`Client::connect`](super::Client::connect) — that stream is
1251    /// consumed first. When it ends (or any later GET reconnect ends),
1252    /// we sleep `backoff_initial_interval` and open a fresh GET `/` SSE
1253    /// stream.
1254    ///
1255    /// Takes a [`Weak<Self>`] (not `Arc<Self>`) so the spawned task
1256    /// doesn't itself keep the [`Connection`] alive, and a
1257    /// [`CancellationToken`] sibling clone of the connection's
1258    /// [`DropGuard`] so the task tears down the instant the last
1259    /// external `Arc<ConnectionInner>` clone is dropped — every
1260    /// blocking await (line read, reconnect send, backoff sleep) is
1261    /// raced against `cancel.cancelled()` and exits without any zombie
1262    /// retries against a now-dead session.
1263    async fn listen_for_list_changes(
1264        weak: Weak<Self>,
1265        cancel: CancellationToken,
1266        initial_lines: super::LinesStream,
1267    ) {
1268        // First iteration: use the pre-opened SSE stream the client
1269        // handed us. After that, fall back to opening fresh GET / SSE
1270        // streams as the upstream connection cycles.
1271        let mut next_lines: Option<super::LinesStream> = Some(initial_lines);
1272        // One-shot guard for the catch-up refresh: false on the very
1273        // first iteration (the caller's pre-opened SSE stream — its
1274        // associated cache was just populated by `Client::connect`'s
1275        // initial pagination, so re-fetching there would just be a
1276        // wasted round-trip), true thereafter. Every stream end —
1277        // whether `Ok(None)` (clean close) or `Err(_)` (read failure)
1278        // — drops back here, which we treat as an implicit
1279        // list-changed notification: the upstream's broadcast (in
1280        // particular the proxy's per-session `tokio::broadcast`) is
1281        // lossy for moments when this listener has zero active
1282        // subscribers, so anything that fired during our disconnect
1283        // window may have been dropped.
1284        //
1285        // ORDER MATTERS. The refresh must run AFTER we've re-opened
1286        // the GET / SSE stream — i.e. after we're a subscriber again
1287        // — and BEFORE we enter the inner read loop. If we refreshed
1288        // before the resubscribe, a notification that fired between
1289        // our refresh-completion and our subscribe would be lost the
1290        // same way as the original disconnect-window drops; doing it
1291        // after means a notification fired DURING the refresh lands
1292        // in the new subscriber's buffer (broadcast::Sender::send
1293        // backs onto each receiver's channel-capacity slot) and gets
1294        // consumed by the inner loop on its next read.
1295        let mut is_reconnect = false;
1296
1297        loop {
1298            // The token cancels deterministically when the last
1299            // `Arc<ConnectionInner>` clone is dropped (see
1300            // `_listener_cancel_guard`). Check once per outer
1301            // iteration, but the real protection is the cancel arms in
1302            // every blocking await below — those exit immediately on
1303            // cancel.
1304            if cancel.is_cancelled() {
1305                return;
1306            }
1307            let Some(this) = weak.upgrade() else { return };
1308            let backoff_delay = this.backoff_initial_interval;
1309
1310            let mut lines = match next_lines.take() {
1311                Some(l) => l,
1312                None => {
1313                    // Race the upstream GET against cancellation — if
1314                    // the connection drops mid-reconnect, exit
1315                    // immediately rather than waiting for the request
1316                    // to complete or time out (otherwise produces a
1317                    // burst of 401 retries against a now-dead session
1318                    // under heavy churn).
1319                    let send_outcome = tokio::select! {
1320                        out = async {
1321                            this.get().await.send().await
1322                        } => out,
1323                        _ = cancel.cancelled() => {
1324                            drop(this);
1325                            return;
1326                        }
1327                    };
1328                    let response = match send_outcome {
1329                        Ok(r) if r.status().is_success() => r,
1330                        _ => {
1331                            drop(this);
1332                            // Sleep with cancel-arm: instant exit on
1333                            // drop, no zombie retries.
1334                            tokio::select! {
1335                                _ = tokio::time::sleep(backoff_delay) => {}
1336                                _ = cancel.cancelled() => return,
1337                            }
1338                            continue;
1339                        }
1340                    };
1341                    super::lines_from_response(response)
1342                }
1343            };
1344
1345            // Catch-up refresh on every reconnect — the implicit
1346            // list-changed treatment for the just-failed stream. See
1347            // the `is_reconnect` doc-comment above for the
1348            // refresh-AFTER-resubscribe rationale.
1349            if is_reconnect {
1350                // tools and resources are independent locks; run the
1351                // catch-up refreshes concurrently so disconnect
1352                // recovery isn't sequential.
1353                let _ = tokio::join!(
1354                    this.refresh_tools(this.on_tools_list_changed.get()),
1355                    this.refresh_resources(
1356                        this.on_resources_list_changed.get()
1357                    ),
1358                );
1359            }
1360            is_reconnect = true;
1361
1362            'inner: loop {
1363                tokio::select! {
1364                    line_result = lines.next_line() => {
1365                        match line_result {
1366                            Ok(Some(line)) => {
1367                                // SSE data lines start with "data: ".
1368                                let Some(data) = line.strip_prefix("data: ") else {
1369                                    continue 'inner;
1370                                };
1371                                let method = match serde_json::from_str::<super::JsonRpcNotification>(data) {
1372                                    Ok(n) => n.method,
1373                                    Err(_) => continue 'inner,
1374                                };
1375                                match method.as_str() {
1376                                    "notifications/tools/list_changed" => {
1377                                        // refresh_tools fires the
1378                                        // callback after the cache is
1379                                        // installed, so the proxy's
1380                                        // downstream
1381                                        // notifications/tools/list_changed
1382                                        // emission lines up with the
1383                                        // staleness window opening.
1384                                        this.refresh_tools(
1385                                            this.on_tools_list_changed.get(),
1386                                        )
1387                                        .await;
1388                                    }
1389                                    "notifications/resources/list_changed" => {
1390                                        this.refresh_resources(
1391                                            this.on_resources_list_changed.get(),
1392                                        )
1393                                        .await;
1394                                    }
1395                                    _ => {}
1396                                }
1397                            }
1398                            // Stream ended cleanly or errored — break out
1399                            // to the outer loop so we either reconnect or,
1400                            // if everyone's gone, exit at the top.
1401                            _ => break 'inner,
1402                        }
1403                    }
1404                    // Cancellation: the connection's last clone has
1405                    // dropped. Tear down immediately.
1406                    _ = cancel.cancelled() => {
1407                        drop(this);
1408                        return;
1409                    }
1410                }
1411            }
1412
1413            // Stream dropped — empty the per-Connection caches so the
1414            // next `list_tools` / `list_resources` paginates inline
1415            // against the (possibly still-dead) upstream rather than
1416            // returning whatever was cached before the drop. The
1417            // `is_reconnect` catch-up at the top of the next outer
1418            // iteration will repopulate `Some(_)` if the reconnect
1419            // succeeds; if the reconnect keeps failing, the cache
1420            // stays `None` and `list_*` callers paginate themselves.
1421            *this.tools.write().await = None;
1422            *this.resources.write().await = None;
1423
1424            // Stream ended — drop the strong ref before sleeping so the
1425            // next iteration's weak-upgrade can detect liveness honestly.
1426            drop(this);
1427            tokio::select! {
1428                _ = tokio::time::sleep(backoff_delay) => {}
1429                _ = cancel.cancelled() => return,
1430            }
1431        }
1432    }
1433}
1434
1435#[cfg(test)]
1436mod capability_gate_tests {
1437    use super::*;
1438    use crate::mcp::initialize_result::{
1439        ResourcesCapability, ServerCapabilities, ToolsCapability,
1440    };
1441    use crate::mcp::tool::{
1442        CallToolRequestParams, Tool, ToolSchemaObject, ToolSchemaType,
1443    };
1444
1445    /// Builds a `ServerCapabilities` with the given `tools` / `resources`
1446    /// shapes and every other capability set to `None`. Each gate test
1447    /// passes its own combination to exercise a specific cap-absent
1448    /// branch.
1449    fn caps(
1450        tools: Option<ToolsCapability>,
1451        resources: Option<ResourcesCapability>,
1452    ) -> ServerCapabilities {
1453        ServerCapabilities {
1454            experimental: None,
1455            logging: None,
1456            completions: None,
1457            prompts: None,
1458            resources,
1459            tools,
1460            tasks: None,
1461        }
1462    }
1463
1464    fn tool(name: &str) -> Tool {
1465        Tool {
1466            name: name.to_string(),
1467            title: None,
1468            description: None,
1469            icons: None,
1470            input_schema: ToolSchemaObject {
1471                r#type: ToolSchemaType::Object,
1472                properties: None,
1473                required: None,
1474                extra: IndexMap::new(),
1475            },
1476            output_schema: None,
1477            annotations: None,
1478            execution: None,
1479            _meta: None,
1480        }
1481    }
1482
1483    /// 3.1 — `list_tools` short-circuits to `Ok(empty)` when the server
1484    /// declared no `tools` capability, *before* the cache is consulted.
1485    /// We poison the cache with `Err` to prove the gate fires first.
1486    #[tokio::test]
1487    async fn list_tools_returns_empty_when_tools_cap_absent() {
1488        let conn = Connection::new_for_test_with_caps(
1489            "t".into(),
1490            "http://x".into(),
1491            caps(None, None),
1492        );
1493        let err = super::super::Error::NoSessionId {
1494            url: "http://x".into(),
1495            body: String::new(),
1496        };
1497        *conn.inner.tools.write().await = Some(Err(Arc::new(err)));
1498
1499        let got = conn.list_tools().await.unwrap();
1500        assert!(got.is_empty());
1501    }
1502
1503    /// 3.2 — symmetric to 3.1 for `list_resources`.
1504    #[tokio::test]
1505    async fn list_resources_returns_empty_when_resources_cap_absent() {
1506        let conn = Connection::new_for_test_with_caps(
1507            "t".into(),
1508            "http://x".into(),
1509            caps(None, None),
1510        );
1511        let err = super::super::Error::NoSessionId {
1512            url: "http://x".into(),
1513            body: String::new(),
1514        };
1515        *conn.inner.resources.write().await = Some(Err(Arc::new(err)));
1516
1517        let got = conn.list_resources().await.unwrap();
1518        assert!(got.is_empty());
1519    }
1520
1521    /// 3.3 — `read_resource` errors with `UnsupportedCapability` when
1522    /// the server declared no `resources` capability, without hitting
1523    /// the network.
1524    #[tokio::test]
1525    async fn read_resource_errors_when_resources_cap_absent() {
1526        let conn = Connection::new_for_test_with_caps(
1527            "t".into(),
1528            "http://x".into(),
1529            caps(None, None),
1530        );
1531        let got = conn.read_resource("file://nope").await;
1532        assert!(matches!(
1533            got,
1534            Err(super::super::Error::UnsupportedCapability {
1535                capability: "resources"
1536            })
1537        ));
1538    }
1539
1540    /// 3.4 — `call_tool` errors with `UnsupportedCapability` when the
1541    /// server declared no `tools` capability.
1542    #[tokio::test]
1543    async fn call_tool_errors_when_tools_cap_absent() {
1544        let conn = Connection::new_for_test_with_caps(
1545            "t".into(),
1546            "http://x".into(),
1547            caps(None, None),
1548        );
1549        let params = CallToolRequestParams {
1550            name: "any".into(),
1551            arguments: None,
1552            _meta: None,
1553            task: None,
1554        };
1555        let got = conn.call_tool(&params).await;
1556        assert!(matches!(
1557            got,
1558            Err(super::super::Error::UnsupportedCapability {
1559                capability: "tools"
1560            })
1561        ));
1562    }
1563
1564    /// 3.5 — `refresh_tools` installs `Some(Ok(empty))` and returns
1565    /// without paginating when the server declared no `tools`
1566    /// capability. Clearing the cache first proves the install ran.
1567    #[tokio::test]
1568    async fn refresh_tools_installs_empty_when_tools_cap_absent() {
1569        let conn = Connection::new_for_test_with_caps(
1570            "t".into(),
1571            "http://x".into(),
1572            caps(None, None),
1573        );
1574        *conn.inner.tools.write().await = None;
1575
1576        conn.inner.refresh_tools(None).await;
1577
1578        let guard = conn.inner.tools.read().await;
1579        let v = guard
1580            .as_ref()
1581            .expect("refresh installed Some")
1582            .as_ref()
1583            .expect("refresh installed Ok");
1584        assert!(v.is_empty());
1585    }
1586
1587    /// 3.6 — symmetric to 3.5 for `refresh_resources`.
1588    #[tokio::test]
1589    async fn refresh_resources_installs_empty_when_resources_cap_absent() {
1590        let conn = Connection::new_for_test_with_caps(
1591            "t".into(),
1592            "http://x".into(),
1593            caps(None, None),
1594        );
1595        *conn.inner.resources.write().await = None;
1596
1597        conn.inner.refresh_resources(None).await;
1598
1599        let guard = conn.inner.resources.read().await;
1600        let v = guard
1601            .as_ref()
1602            .expect("refresh installed Some")
1603            .as_ref()
1604            .expect("refresh installed Ok");
1605        assert!(v.is_empty());
1606    }
1607
1608}