Skip to main content

yapi_core/mcp/
connection.rs

1//! One configured server's connection: lazy connects and reconnects, its
2//! tools and resources, its credentials, and its state for `/mcp`. Port of
3//! `extensions/mcp/runtime.ts` in pi `v1.0.0`.
4
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use serde_json::Value;
11use yapi_types::sync::lock;
12
13use super::client::{ClientOptions, McpClient, RequestOptions, ResourceKind, Root, Tool};
14use super::config::{ServerEntry, ServerTransport};
15use super::http::{HttpAuth, HttpOptions, HttpTransport, ProviderToken};
16use super::jsonrpc::{METHOD_NOT_FOUND, McpError};
17use super::sign_in::{CredentialStore, McpAuth, SignInPrompt};
18use super::stdio::{StdioOptions, StdioTransport};
19use super::tools::is_app_resource;
20use super::transport::Transport;
21
22const DEFAULT_TIMEOUT_SECONDS: f64 = 60.0;
23const STDERR_TAIL_CHARS: usize = 2_000;
24/// Delays between attempts to reach an HTTP server after a transient error.
25const CONNECT_RETRY_DELAYS: [Duration; 2] =
26    [Duration::from_millis(250), Duration::from_millis(1_000)];
27
28/// Where a connection stands.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum State {
31    /// Connecting.
32    Connecting,
33    /// Connected.
34    Connected,
35    /// The connection dropped, and the next call reconnects.
36    Disconnected,
37    /// The server needs a sign-in.
38    NeedsAuth,
39    /// Connecting failed.
40    Failed,
41    /// Shut down.
42    Closed,
43}
44
45impl State {
46    /// pi's name for the state.
47    pub fn as_str(self) -> &'static str {
48        match self {
49            State::Connecting => "connecting",
50            State::Connected => "connected",
51            State::Disconnected => "disconnected",
52            State::NeedsAuth => "needs-auth",
53            State::Failed => "failed",
54            State::Closed => "closed",
55        }
56    }
57}
58
59/// What a connection knows of its server.
60#[derive(Clone, Debug)]
61pub struct Snapshot {
62    /// Where it stands.
63    pub state: State,
64    /// Why it failed or dropped.
65    pub error: Option<String>,
66    /// The server's tools.
67    pub tools: Vec<Tool>,
68    /// Whether the server offers resources.
69    pub has_resources: bool,
70    /// Resources listed at the last connect, without MCP App resources.
71    pub resources: Vec<Value>,
72    /// Server instructions from `initialize`.
73    pub instructions: Option<String>,
74}
75
76/// Called when the server's tools or resources change.
77pub type ToolsListener = Arc<dyn Fn(&Arc<Connection>) + Send + Sync>;
78
79/// A configured server, connected on demand.
80pub struct Connection {
81    entry: ServerEntry,
82    cwd: PathBuf,
83    agent_dir: PathBuf,
84    snapshot: Mutex<Snapshot>,
85    client: Mutex<Option<McpClient>>,
86    opening: tokio::sync::Mutex<()>,
87    closed: AtomicBool,
88    on_tools: ToolsListener,
89    readable_resources: Arc<dyn Fn(&str) -> bool + Send + Sync>,
90    auth: Option<HttpAuth>,
91    credentials: CredentialStore,
92    /// The stored tokens when the server came to need a sign-in, to notice
93    /// sign-ins of another process, such as `yapi mcp login`.
94    tokens_at_sign_in: Mutex<Option<String>>,
95}
96
97/// pi's `resolveConfigValueOrThrow`: environment references and `!command`.
98pub(super) async fn resolve_value(value: &str, description: &str) -> Result<String, McpError> {
99    if let Some(resolved) = yapi_ai::credentials::resolve(value, None, false).await {
100        return Ok(resolved);
101    }
102    if let Some(command) = value.strip_prefix('!') {
103        return Err(McpError::Other(format!(
104            "Failed to resolve {description} from shell command: {command}"
105        )));
106    }
107    let missing: Vec<String> = yapi_ai::credentials::env_var_names(value)
108        .into_iter()
109        .filter(|name| std::env::var(name).map_or(true, |value| value.is_empty()))
110        .collect();
111    Err(McpError::Other(match missing.as_slice() {
112        [name] => format!("Failed to resolve {description} from environment variable: {name}"),
113        [] => format!("Failed to resolve {description}"),
114        names => format!(
115            "Failed to resolve {description} from environment variables: {}",
116            names.join(", ")
117        ),
118    }))
119}
120
121impl Connection {
122    /// A connection for `entry`, not yet connected, with OAuth credentials
123    /// kept in `agent_dir`. Its callbacks:
124    /// - `on_tools`: learns of tool and resource changes
125    /// - `readable_resources`: tells whether the resource tools reach a server
126    /// - `provider_token`: reads the token of servers with `auth.provider`
127    pub fn new(
128        entry: ServerEntry,
129        cwd: PathBuf,
130        agent_dir: PathBuf,
131        on_tools: ToolsListener,
132        readable_resources: Arc<dyn Fn(&str) -> bool + Send + Sync>,
133        provider_token: Option<ProviderToken>,
134    ) -> Arc<Connection> {
135        let credentials = CredentialStore::new(&agent_dir);
136        let auth = match &entry.config.transport {
137            transport @ ServerTransport::Http {
138                url,
139                auth_provider,
140                oauth,
141                ..
142            } => {
143                if transport.uses_oauth() {
144                    Some(HttpAuth::OAuth(Arc::new(McpAuth::new(
145                        &entry.name,
146                        url,
147                        &credentials,
148                        oauth.clone().unwrap_or_default(),
149                    ))))
150                } else {
151                    // Read on every request, so the provider's refreshes apply. MCP stores no copy.
152                    auth_provider.clone().map(|provider| HttpAuth::Provider {
153                        provider,
154                        token: provider_token,
155                    })
156                }
157            }
158            ServerTransport::Stdio { .. } => None,
159        };
160        Arc::new(Connection {
161            entry,
162            cwd,
163            agent_dir,
164            snapshot: Mutex::new(Snapshot {
165                state: State::Connecting,
166                error: None,
167                tools: Vec::new(),
168                has_resources: false,
169                resources: Vec::new(),
170                instructions: None,
171            }),
172            client: Mutex::new(None),
173            opening: tokio::sync::Mutex::new(()),
174            closed: AtomicBool::new(false),
175            on_tools,
176            readable_resources,
177            auth,
178            credentials,
179            tokens_at_sign_in: Mutex::new(None),
180        })
181    }
182
183    /// The server URL when the server signs in with OAuth.
184    pub fn oauth_url(&self) -> Option<&str> {
185        match &self.entry.config.transport {
186            transport @ ServerTransport::Http { url, .. } if transport.uses_oauth() => Some(url),
187            _ => None,
188        }
189    }
190
191    fn oauth(&self) -> Option<&Arc<McpAuth>> {
192        match &self.auth {
193            Some(HttpAuth::OAuth(auth)) => Some(auth),
194            _ => None,
195        }
196    }
197
198    /// Signs in to an OAuth server. The caller reconnects afterwards.
199    pub async fn sign_in(&self, prompt: &SignInPrompt) -> Result<(), yapi_ai::auth::AuthError> {
200        match self.oauth() {
201            Some(auth) => auth.sign_in(prompt).await,
202            None => Err(yapi_ai::auth::AuthError::Failed(format!(
203                "MCP server \"{}\" does not use OAuth.",
204                self.entry.name
205            ))),
206        }
207    }
208
209    /// Removes the server's stored OAuth credentials. Returns whether any
210    /// were stored.
211    pub async fn remove_credentials(&self) -> Result<bool, McpError> {
212        match self.oauth_url() {
213            Some(url) => self.credentials.remove(&self.entry.name, url).await,
214            None => Ok(false),
215        }
216    }
217
218    /// Disconnects after the stored credentials were removed.
219    pub async fn sign_out(&self) {
220        let _opening = self.opening.lock().await;
221        let client = lock(&self.client).take();
222        if let Some(client) = client {
223            client.close().await;
224        }
225        if !self.closed.load(Ordering::SeqCst) {
226            self.mark_needs_auth().await;
227        }
228    }
229
230    /// Whether the server needs a sign-in and its stored credentials changed
231    /// since it came to, as when another process signed in.
232    pub async fn signed_in_elsewhere(&self) -> bool {
233        let Some(before) = lock(&self.tokens_at_sign_in).clone() else {
234            return false;
235        };
236        self.snapshot().state == State::NeedsAuth && self.stored_tokens().await != before
237    }
238
239    async fn stored_tokens(&self) -> String {
240        let tokens = match self.oauth_url() {
241            Some(url) => self
242                .credentials
243                .tokens(&self.entry.name, url)
244                .await
245                .ok()
246                .flatten(),
247            None => None,
248        };
249        yapi_types::json::stringify(&tokens.unwrap_or(Value::Null))
250    }
251
252    /// OAuth servers that still reject requests after a refresh need the user
253    /// to sign in again.
254    fn needs_sign_in(&self, error: &McpError) -> bool {
255        matches!(error, McpError::SignInRequired)
256            || (self.auth.is_some() && matches!(error, McpError::AuthRequired { .. }))
257    }
258
259    fn sign_in_required_message(&self) -> String {
260        let command = match &self.entry.config.transport {
261            ServerTransport::Http {
262                auth_provider: Some(provider),
263                ..
264            } => format!("/login {provider}"),
265            _ => "/mcp".to_owned(),
266        };
267        format!(
268            "MCP server \"{}\" requires sign-in. Run {command} to sign in.",
269            self.entry.name
270        )
271    }
272
273    async fn mark_needs_auth(&self) {
274        self.set_state(State::NeedsAuth, None);
275        if lock(&self.tokens_at_sign_in).is_none() {
276            let tokens = self.stored_tokens().await;
277            lock(&self.tokens_at_sign_in).get_or_insert(tokens);
278        }
279    }
280
281    /// The server's name.
282    pub fn name(&self) -> &str {
283        &self.entry.name
284    }
285
286    /// The configured entry.
287    pub fn entry(&self) -> &ServerEntry {
288        &self.entry
289    }
290
291    /// The per-request timeout.
292    pub fn timeout(&self) -> Duration {
293        Duration::try_from_secs_f64(self.entry.config.timeout.unwrap_or(DEFAULT_TIMEOUT_SECONDS))
294            .unwrap_or(Duration::MAX)
295    }
296
297    /// What it knows of its server.
298    pub fn snapshot(&self) -> Snapshot {
299        lock(&self.snapshot).clone()
300    }
301
302    /// Where it stands.
303    pub fn state(&self) -> State {
304        lock(&self.snapshot).state
305    }
306
307    /// Whether `read_mcp_resource` reaches this server.
308    pub fn readable_resources(&self) -> bool {
309        (self.readable_resources)(&self.entry.name)
310    }
311
312    fn connected_client(&self) -> Option<McpClient> {
313        lock(&self.client)
314            .as_ref()
315            .filter(|client| client.is_connected())
316            .cloned()
317    }
318
319    /// The connected client, connecting when needed.
320    pub async fn client(self: &Arc<Self>) -> Result<McpClient, McpError> {
321        if self.closed.load(Ordering::SeqCst) {
322            return Err(McpError::Other(format!(
323                "MCP server \"{}\" is shut down",
324                self.entry.name
325            )));
326        }
327        if let Some(client) = self.connected_client() {
328            return Ok(client);
329        }
330        let _opening = self.opening.lock().await;
331        if let Some(client) = self.connected_client() {
332            return Ok(client);
333        }
334        self.open().await
335    }
336
337    async fn open(self: &Arc<Self>) -> Result<McpClient, McpError> {
338        self.set_state(State::Connecting, None);
339        let http = matches!(self.entry.config.transport, ServerTransport::Http { .. });
340        let mut attempt = 0;
341        loop {
342            match self.connect_once().await {
343                Ok(client) => return Ok(client),
344                Err(failure) => {
345                    let (error, stderr) = *failure;
346                    if self.needs_sign_in(&error) && !self.closed.load(Ordering::SeqCst) {
347                        self.mark_needs_auth().await;
348                        return Err(McpError::Other(self.sign_in_required_message()));
349                    }
350                    let delay = http.then(|| CONNECT_RETRY_DELAYS.get(attempt)).flatten();
351                    attempt += 1;
352                    match delay {
353                        Some(delay)
354                            if error.is_transient() && !self.closed.load(Ordering::SeqCst) =>
355                        {
356                            tokio::time::sleep(*delay).await;
357                        }
358                        _ => return Err(self.connect_failed(&error, stderr)),
359                    }
360                }
361            }
362        }
363    }
364
365    async fn transport(&self) -> Result<Transport, McpError> {
366        let name = &self.entry.name;
367        match &self.entry.config.transport {
368            ServerTransport::Http { url, headers, .. } => {
369                let mut resolved = Vec::new();
370                for (key, value) in headers {
371                    let description = format!("MCP server \"{name}\" header \"{key}\"");
372                    resolved.push((key.clone(), resolve_value(value, &description).await?));
373                }
374                Ok(Transport::Http(HttpTransport::new(HttpOptions {
375                    url: url.clone(),
376                    headers: resolved,
377                    auth: self.auth.clone(),
378                })))
379            }
380            ServerTransport::Stdio {
381                command,
382                args,
383                env,
384                cwd,
385            } => {
386                let mut resolved = Vec::new();
387                for (key, value) in env {
388                    let description = format!("MCP server \"{name}\" env \"{key}\"");
389                    resolved.push((key.clone(), resolve_value(value, &description).await?));
390                }
391                Ok(Transport::Stdio(Box::new(StdioTransport::new(
392                    StdioOptions {
393                        command: crate::tools::path::expand_home(command),
394                        args: args
395                            .iter()
396                            .map(|arg| crate::tools::path::expand_home(arg))
397                            .collect(),
398                        cwd: self.cwd.join(crate::tools::path::expand_home(
399                            cwd.as_deref().unwrap_or("."),
400                        )),
401                        env: resolved,
402                    },
403                ))))
404            }
405        }
406    }
407
408    /// One attempt. On failure, the error and a stdio server's stderr.
409    async fn connect_once(self: &Arc<Self>) -> Result<McpClient, Box<(McpError, Option<String>)>> {
410        let transport = self
411            .transport()
412            .await
413            .map_err(|error| Box::new((error, None)))?;
414        let options = ClientOptions {
415            name: "yapi".into(),
416            version: env!("CARGO_PKG_VERSION").into(),
417            request_timeout: self.timeout(),
418            roots: vec![Root {
419                uri: crate::tools::path::file_url(&self.cwd),
420                name: self
421                    .cwd
422                    .file_name()
423                    .map(|name| name.to_string_lossy().into_owned()),
424            }],
425        };
426        let client = McpClient::connect_with_stderr(options, transport).await?;
427        let log = super::log::Log::new(self.agent_dir.join("mcp.log"));
428        let server = self.entry.name.clone();
429        client.on_notification("notifications/message", move |params| {
430            log.write(&server, params)
431        });
432        let weak = Arc::downgrade(self);
433        let refreshing = client.clone();
434        client.on_notification("notifications/tools/list_changed", move |_| {
435            if let Some(connection) = weak.upgrade() {
436                let client = refreshing.clone();
437                tokio::spawn(async move { connection.refresh_tools(client).await });
438            }
439        });
440        let weak = Arc::downgrade(self);
441        let refreshing = client.clone();
442        client.on_notification("notifications/resources/list_changed", move |_| {
443            if let Some(connection) = weak.upgrade() {
444                let client = refreshing.clone();
445                tokio::spawn(async move { connection.refresh_resources(client).await });
446            }
447        });
448        let weak = Arc::downgrade(self);
449        let watched = client.clone();
450        client.on_close(move || {
451            if let Some(connection) = weak.upgrade() {
452                connection.client_closed(&watched);
453            }
454        });
455        let capabilities = client
456            .server()
457            .map(|server| server.capabilities)
458            .unwrap_or_default();
459        let has_resources = capabilities.contains_key("resources");
460        let setup = async {
461            let tools = if capabilities.contains_key("tools") {
462                client.list_tools(RequestOptions::default()).await?
463            } else {
464                Vec::new()
465            };
466            let resources = if has_resources {
467                fetch_resources(&client).await
468            } else {
469                Vec::new()
470            };
471            Ok::<_, McpError>((tools, resources))
472        };
473        let (tools, resources) = match setup.await {
474            Ok(found) => found,
475            Err(error) => {
476                let stderr = client.stderr();
477                client.close().await;
478                return Err(Box::new((error, stderr)));
479            }
480        };
481        if self.closed.load(Ordering::SeqCst) || !client.is_connected() {
482            let stderr = client.stderr();
483            client.close().await;
484            let reason = if self.closed.load(Ordering::SeqCst) {
485                "shut down while connecting"
486            } else {
487                "connection closed during setup"
488            };
489            return Err(Box::new((McpError::Other(reason.into()), stderr)));
490        }
491        let instructions = client
492            .server()
493            .and_then(|server| server.instructions)
494            .map(|text| text.trim().to_owned())
495            .filter(|text| !text.is_empty());
496        *lock(&self.client) = Some(client.clone());
497        {
498            let mut snapshot = lock(&self.snapshot);
499            snapshot.tools = tools;
500            snapshot.has_resources = has_resources;
501            snapshot.resources = resources;
502            snapshot.instructions = instructions;
503            snapshot.state = State::Connected;
504            snapshot.error = None;
505        }
506        *lock(&self.tokens_at_sign_in) = None;
507        (self.on_tools)(self);
508        Ok(client)
509    }
510
511    fn connect_failed(&self, error: &McpError, stderr: Option<String>) -> McpError {
512        let closed = self.closed.load(Ordering::SeqCst);
513        let tail = stderr
514            .map(|text| {
515                let text = text.trim();
516                let start = text
517                    .char_indices()
518                    .rev()
519                    .nth(STDERR_TAIL_CHARS - 1)
520                    .map_or(0, |(index, _)| index);
521                text[start..].to_owned()
522            })
523            .filter(|text| !text.is_empty());
524        let message = match tail {
525            Some(tail) => format!("{error}\n{tail}"),
526            None => error.to_string(),
527        };
528        self.set_state(
529            if closed { State::Closed } else { State::Failed },
530            Some(message.clone()),
531        );
532        McpError::Other(format!(
533            "MCP server \"{}\" failed to connect: {message}",
534            self.entry.name
535        ))
536    }
537
538    fn set_state(&self, state: State, error: Option<String>) {
539        let mut snapshot = lock(&self.snapshot);
540        snapshot.state = state;
541        snapshot.error = error;
542        if state != State::NeedsAuth {
543            *lock(&self.tokens_at_sign_in) = None;
544        }
545    }
546
547    /// The transport dropped, and the next call reconnects.
548    fn client_closed(&self, client: &McpClient) {
549        let mut current = lock(&self.client);
550        if !current.as_ref().is_some_and(|current| current.same(client))
551            || self.closed.load(Ordering::SeqCst)
552        {
553            return;
554        }
555        *current = None;
556        drop(current);
557        let stderr = client
558            .stderr()
559            .map(|text| text.trim().to_owned())
560            .filter(|text| !text.is_empty());
561        let error = match stderr {
562            Some(stderr) => format!("Connection closed\n{stderr}"),
563            None => "Connection closed".to_owned(),
564        };
565        self.set_state(State::Disconnected, Some(error));
566    }
567
568    async fn refresh_tools(self: Arc<Self>, client: McpClient) {
569        match client.list_tools(RequestOptions::default()).await {
570            Ok(tools) => {
571                if self.closed.load(Ordering::SeqCst) || !client.is_connected() {
572                    return;
573                }
574                lock(&self.snapshot).tools = tools;
575                (self.on_tools)(&self);
576            }
577            Err(error) => {
578                lock(&self.snapshot).error = Some(format!("Failed to refresh tools: {error}"));
579            }
580        }
581    }
582
583    async fn refresh_resources(self: Arc<Self>, client: McpClient) {
584        let resources = fetch_resources(&client).await;
585        if self.closed.load(Ordering::SeqCst) || !client.is_connected() {
586            return;
587        }
588        lock(&self.snapshot).resources = resources;
589        (self.on_tools)(&self);
590    }
591
592    /// Runs a request, reconnecting when needed. Read-only requests are
593    /// retried once after a transient HTTP error. Any request is retried once
594    /// on a new session when the server forgot the old one.
595    async fn with_client<T, F>(
596        self: &Arc<Self>,
597        read_only: bool,
598        run: impl Fn(McpClient) -> F,
599    ) -> Result<T, McpError>
600    where
601        F: std::future::Future<Output = Result<T, McpError>>,
602    {
603        let mut attempt = 1;
604        loop {
605            let client = self.client().await?;
606            match run(client.clone()).await {
607                Ok(value) => return Ok(value),
608                Err(error)
609                    if read_only
610                        && attempt == 1
611                        && error.status().is_some()
612                        && error.is_transient() =>
613                {
614                    tokio::time::sleep(CONNECT_RETRY_DELAYS[0]).await;
615                }
616                // The server forgot the session, so it did not run the request.
617                // The old client is detached, not closed: its other calls in
618                // flight get the same answer and retry the same way.
619                Err(McpError::SessionExpired) if attempt == 1 => {
620                    let mut current = lock(&self.client);
621                    if current
622                        .as_ref()
623                        .is_some_and(|current| current.same(&client))
624                    {
625                        *current = None;
626                    }
627                }
628                Err(error) if self.needs_sign_in(&error) => {
629                    {
630                        let mut current = lock(&self.client);
631                        if current
632                            .as_ref()
633                            .is_some_and(|current| current.same(&client))
634                        {
635                            *current = None;
636                        }
637                    }
638                    client.close().await;
639                    self.mark_needs_auth().await;
640                    return Err(McpError::Other(self.sign_in_required_message()));
641                }
642                Err(error) => return Err(error),
643            }
644            attempt += 1;
645        }
646    }
647
648    /// Calls one of the server's tools. Not retried, since it may have run.
649    pub async fn call_tool(
650        self: &Arc<Self>,
651        name: &str,
652        args: Value,
653        options: RequestOptions,
654    ) -> Result<Value, McpError> {
655        self.with_client(false, |client| {
656            let (args, options) = (args.clone(), options.clone());
657            async move { client.call_tool(name, args, options).await }
658        })
659        .await
660    }
661
662    /// Reads a resource.
663    pub async fn read_resource(
664        self: &Arc<Self>,
665        uri: &str,
666        options: RequestOptions,
667    ) -> Result<Value, McpError> {
668        self.with_client(true, |client| {
669            let options = options.clone();
670            async move { client.read_resource(uri, options).await }
671        })
672        .await
673    }
674
675    /// One page of resources or resource templates, with no templates for
676    /// servers without the method.
677    pub async fn resources_page(
678        self: &Arc<Self>,
679        kind: ResourceKind,
680        cursor: Option<String>,
681        options: RequestOptions,
682    ) -> Result<(Vec<Value>, Option<String>), McpError> {
683        self.with_client(true, |client| {
684            let (cursor, options) = (cursor.clone(), options.clone());
685            async move {
686                let page = client.list_resources_page(kind, cursor, options).await;
687                without_templates(kind, page, (Vec::new(), None))
688            }
689        })
690        .await
691    }
692
693    /// Every resource or resource template, with no templates for servers
694    /// without the method.
695    pub async fn all_resources(
696        self: &Arc<Self>,
697        kind: ResourceKind,
698        options: RequestOptions,
699    ) -> Result<Vec<Value>, McpError> {
700        self.with_client(true, |client| {
701            let options = options.clone();
702            async move {
703                without_templates(kind, client.list_resources(kind, options).await, Vec::new())
704            }
705        })
706        .await
707    }
708
709    /// Connects again.
710    pub async fn reconnect(self: &Arc<Self>) -> Result<(), McpError> {
711        let _opening = self.opening.lock().await;
712        let client = lock(&self.client).take();
713        if let Some(client) = client {
714            client.close().await;
715        }
716        self.open().await.map(|_| ())
717    }
718
719    /// Shuts the connection down for good. A refresh in flight is awaited:
720    /// the server may already have rotated the refresh token.
721    pub async fn close(&self) {
722        self.closed.store(true, Ordering::SeqCst);
723        self.set_state(State::Closed, None);
724        let client = lock(&self.client).take();
725        if let Some(client) = client {
726            client.close().await;
727        }
728        if let Some(auth) = self.oauth() {
729            auth.settled().await;
730        }
731    }
732}
733
734/// Servers that do not implement `resources/templates/list` have no templates.
735fn without_templates<T>(
736    kind: ResourceKind,
737    result: Result<T, McpError>,
738    empty: T,
739) -> Result<T, McpError> {
740    match result {
741        Err(McpError::Rpc { code, .. })
742            if kind == ResourceKind::Templates && code == METHOD_NOT_FOUND =>
743        {
744            Ok(empty)
745        }
746        other => other,
747    }
748}
749
750/// Resources at connect time, for the counts in `/mcp`. Failures leave the
751/// list empty, and the resource tools list on demand.
752async fn fetch_resources(client: &McpClient) -> Vec<Value> {
753    client
754        .list_resources(ResourceKind::Resources, RequestOptions::default())
755        .await
756        .unwrap_or_default()
757        .into_iter()
758        .filter(|resource| !is_app_resource(resource))
759        .collect()
760}