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(¶ms).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}