Skip to main content

codex_mcp/mcp/
mod.rs

1pub use auth::McpAuthStatusEntry;
2pub use auth::McpOAuthLoginConfig;
3pub use auth::McpOAuthLoginSupport;
4pub use auth::McpOAuthScopesSource;
5pub use auth::ResolvedMcpOAuthScopes;
6pub use auth::compute_auth_statuses;
7pub use auth::discover_supported_scopes;
8pub use auth::discover_supported_scopes_with_http_client;
9pub use auth::oauth_login_support;
10pub use auth::oauth_login_support_with_http_client;
11pub use auth::resolve_oauth_scopes;
12pub use auth::should_retry_without_scopes;
13
14pub(crate) mod auth;
15
16use std::collections::HashMap;
17use std::collections::HashSet;
18use std::env;
19use std::path::PathBuf;
20use std::time::Duration;
21
22use codex_config::Constrained;
23use codex_config::McpServerAuth;
24use codex_config::McpServerConfig;
25use codex_config::McpServerTransportConfig;
26use codex_config::types::AppToolApproval;
27use codex_config::types::AuthKeyringBackendKind;
28use codex_config::types::OAuthCredentialsStoreMode;
29use codex_connectors::ConnectorRuntimeManager;
30use codex_connectors::ConnectorSnapshot;
31use codex_connectors::connector_runtime_context_key;
32use codex_login::CodexAuth;
33use codex_model_provider::CHATGPT_CODEX_BASE_URL;
34use codex_protocol::mcp::McpServerInfo;
35use codex_protocol::mcp::Resource;
36use codex_protocol::mcp::ResourceTemplate;
37use codex_protocol::mcp::Tool;
38use codex_protocol::models::PermissionProfile;
39use codex_protocol::protocol::AskForApproval;
40use codex_protocol::protocol::McpAuthStatus;
41use rmcp::model::ElicitationCapability;
42use rmcp::model::ReadResourceRequestParams;
43use rmcp::model::ReadResourceResult;
44use serde_json::Value;
45use tokio_util::sync::CancellationToken;
46
47use crate::ResolvedMcpCatalog;
48use crate::connection_manager::McpConnectionManager;
49use crate::runtime::McpRuntimeContext;
50use crate::server::EffectiveMcpServer;
51use crate::tools::ToolInfo;
52
53pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
54const DEFAULT_CODEX_APPS_MCP_PRODUCT_SKU: &str = "codex";
55const MCP_TOOL_NAME_PREFIX: &str = "mcp";
56const MCP_TOOL_NAME_DELIMITER: &str = "__";
57const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
58
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
60pub enum McpSnapshotDetail {
61    #[default]
62    Full,
63    ToolsAndAuthOnly,
64}
65
66impl McpSnapshotDetail {
67    fn include_resources(self) -> bool {
68        matches!(self, Self::Full)
69    }
70}
71
72pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
73    sanitize_responses_api_tool_name(&format!(
74        "{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
75    ))
76}
77
78/// Returns true when MCP permission prompts should resolve as approved instead
79/// of being shown to the user.
80pub fn mcp_permission_prompt_is_auto_approved(
81    approval_policy: AskForApproval,
82    permission_profile: &PermissionProfile,
83    context: McpPermissionPromptAutoApproveContext,
84) -> bool {
85    if context.tool_approval_mode == Some(AppToolApproval::Approve) {
86        return true;
87    }
88
89    if approval_policy != AskForApproval::Never {
90        return false;
91    }
92
93    match permission_profile {
94        PermissionProfile::Disabled | PermissionProfile::External { .. } => true,
95        PermissionProfile::Managed { file_system, .. } => {
96            file_system.to_sandbox_policy().has_full_disk_write_access()
97        }
98    }
99}
100
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
102pub struct McpPermissionPromptAutoApproveContext {
103    pub tool_approval_mode: Option<AppToolApproval>,
104}
105
106/// MCP runtime settings derived from `codex_core::config::Config`.
107///
108/// This struct should contain only long-lived configuration values that the
109/// `codex-mcp` crate needs to construct server transports, enforce MCP
110/// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP
111/// servers. Request-scoped or auth-scoped state should not be stored here;
112/// thread those values explicitly into runtime entry points such as
113/// [`effective_mcp_servers`] and snapshot collection helpers so config objects
114/// do not go stale when auth changes.
115#[derive(Debug, Clone)]
116pub struct McpConfig {
117    /// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
118    pub chatgpt_base_url: String,
119    /// Optional product SKU forwarded to the host-owned apps MCP server.
120    pub apps_mcp_product_sku: Option<String>,
121    /// Codex home directory used for MCP OAuth state and app-tool cache files.
122    pub codex_home: PathBuf,
123    /// Preferred credential store for MCP OAuth tokens.
124    pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
125    /// Backend used when MCP OAuth storage is configured for keyring-backed persistence.
126    pub auth_keyring_backend_kind: AuthKeyringBackendKind,
127    /// Optional fixed localhost callback port for MCP OAuth login.
128    pub mcp_oauth_callback_port: Option<u16>,
129    /// Optional OAuth redirect URI override for MCP login.
130    pub mcp_oauth_callback_url: Option<String>,
131    /// Whether skill MCP dependency installation prompts are enabled.
132    pub skill_mcp_dependency_install_enabled: bool,
133    /// Approval policy used for MCP tool calls and MCP elicitation requests.
134    pub approval_policy: Constrained<AskForApproval>,
135    /// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution.
136    pub codex_linux_sandbox_exe: Option<PathBuf>,
137    /// Whether to use legacy Landlock behavior in the MCP sandbox state.
138    pub use_legacy_landlock: bool,
139    /// Whether the app MCP integration is enabled by config.
140    ///
141    /// ChatGPT auth is checked separately before a materialized host-owned Apps
142    /// server can be used.
143    pub apps_enabled: bool,
144    /// Whether model-visible MCP tool namespaces should keep the legacy
145    /// `mcp__` prefix.
146    pub prefix_mcp_tool_names: bool,
147    /// Client-side elicitation capabilities advertised during MCP initialization.
148    pub client_elicitation_capability: ElicitationCapability,
149    /// Resolved MCP registrations keyed by logical server name.
150    pub mcp_server_catalog: ResolvedMcpCatalog,
151    /// Plugin declarations used to attribute connector tools to plugin display names.
152    /// MCP registrations retain their own package attribution in the catalog.
153    pub connector_snapshot: ConnectorSnapshot,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub struct ToolPluginProvenance {
158    plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
159    plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
160    plugin_ids_by_mcp_server_name: HashMap<String, String>,
161    selected_plugin_mcp_server_names: HashSet<String>,
162}
163
164impl ToolPluginProvenance {
165    pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
166        self.plugin_display_names_by_connector_id
167            .get(connector_id)
168            .map(Vec::as_slice)
169            .unwrap_or(&[])
170    }
171
172    pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
173        self.plugin_display_names_by_mcp_server_name
174            .get(server_name)
175            .map(Vec::as_slice)
176            .unwrap_or(&[])
177    }
178
179    pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
180        self.plugin_ids_by_mcp_server_name
181            .get(server_name)
182            .map(String::as_str)
183    }
184
185    pub(crate) fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool {
186        self.selected_plugin_mcp_server_names.contains(server_name)
187    }
188
189    fn from_config(config: &McpConfig) -> Self {
190        let mut tool_plugin_provenance = Self::default();
191        for connector_id in config.connector_snapshot.connector_ids() {
192            tool_plugin_provenance
193                .plugin_display_names_by_connector_id
194                .insert(
195                    connector_id.0.clone(),
196                    config
197                        .connector_snapshot
198                        .plugin_display_names_for_connector_id(&connector_id.0)
199                        .to_vec(),
200                );
201        }
202
203        for (server_name, attribution) in config
204            .mcp_server_catalog
205            .plugin_attributions_by_server_name()
206        {
207            tool_plugin_provenance
208                .plugin_display_names_by_mcp_server_name
209                .insert(
210                    server_name.clone(),
211                    vec![attribution.display_name().to_string()],
212                );
213            tool_plugin_provenance
214                .plugin_ids_by_mcp_server_name
215                .insert(server_name, attribution.plugin_id().to_string());
216        }
217        tool_plugin_provenance
218            .selected_plugin_mcp_server_names
219            .extend(
220                config
221                    .mcp_server_catalog
222                    .selected_plugin_server_names()
223                    .map(str::to_string),
224            );
225
226        for plugin_names in tool_plugin_provenance
227            .plugin_display_names_by_connector_id
228            .values_mut()
229            .chain(
230                tool_plugin_provenance
231                    .plugin_display_names_by_mcp_server_name
232                    .values_mut(),
233            )
234        {
235            plugin_names.sort_unstable();
236            plugin_names.dedup();
237        }
238        tool_plugin_provenance
239    }
240}
241
242pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool {
243    config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)
244}
245
246pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
247    config.mcp_server_catalog.configured_servers()
248}
249
250pub fn effective_mcp_servers(
251    config: &McpConfig,
252    auth: Option<&CodexAuth>,
253) -> HashMap<String, EffectiveMcpServer> {
254    effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)
255}
256
257/// Converts a materialized server map to its auth-gated runtime view.
258///
259/// Compatibility built-ins and extension overlays must already be reflected in
260/// `configured_servers`; this function does not synthesize missing servers.
261pub fn effective_mcp_servers_from_configured(
262    configured_servers: HashMap<String, McpServerConfig>,
263    config: &McpConfig,
264    auth: Option<&CodexAuth>,
265) -> HashMap<String, EffectiveMcpServer> {
266    let chatgpt_origin = url::Url::parse(CHATGPT_CODEX_BASE_URL)
267        .ok()
268        .map(|url| url.origin());
269    let mut servers = configured_servers
270        .into_iter()
271        .map(|(name, mut server)| {
272            match server.auth.clone() {
273                McpServerAuth::ChatGpt => {
274                    let server_origin = match &server.transport {
275                        McpServerTransportConfig::StreamableHttp { url, .. } => {
276                            url::Url::parse(url)
277                                .ok()
278                                .filter(|url| matches!(url.scheme(), "http" | "https"))
279                                .map(|url| url.origin())
280                        }
281                        McpServerTransportConfig::Stdio { .. } => None,
282                    };
283                    if server_origin.as_ref() != chatgpt_origin.as_ref() {
284                        server.auth = McpServerAuth::OAuth;
285                    }
286                }
287                McpServerAuth::OAuth => {}
288            }
289            (name, EffectiveMcpServer::configured(server))
290        })
291        .collect::<HashMap<_, _>>();
292    if !host_owned_codex_apps_enabled(config, auth) {
293        servers.remove(CODEX_APPS_MCP_SERVER_NAME);
294    }
295    servers
296}
297
298pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
299    ToolPluginProvenance::from_config(config)
300}
301
302pub async fn read_mcp_resource(
303    config: &McpConfig,
304    auth: Option<&CodexAuth>,
305    runtime_context: McpRuntimeContext,
306    codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
307    tool_catalog_cache: crate::McpToolCatalogCache,
308    server: &str,
309    uri: &str,
310) -> anyhow::Result<ReadResourceResult> {
311    let mut mcp_servers = effective_mcp_servers(config, auth);
312    mcp_servers.retain(|name, _| name == server);
313    let cancel_token = CancellationToken::new();
314    let manager = McpConnectionManager::new(
315        &mcp_servers,
316        config.mcp_oauth_credentials_store_mode,
317        config.auth_keyring_backend_kind,
318        &config.approval_policy,
319        String::new(),
320        /*tx_event*/ None,
321        cancel_token.clone(),
322        PermissionProfile::default(),
323        runtime_context,
324        config.codex_home.clone(),
325        codex_apps_tools_cache,
326        tool_catalog_cache,
327        connector_runtime_context_key(auth),
328        config.prefix_mcp_tool_names,
329        config.client_elicitation_capability.clone(),
330        /*supports_openai_form_elicitation*/ false,
331        tool_plugin_provenance(config),
332        auth,
333        /*codex_apps_auth_manager*/ None,
334        /*elicitation_reviewer*/ None,
335        /*elicitation_lifecycle*/ None,
336        crate::elicitation::ElicitationRequestRouter::default(),
337    )
338    .await;
339
340    let result = manager
341        .read_resource(server, ReadResourceRequestParams::new(uri))
342        .await;
343    cancel_token.cancel();
344    result
345}
346
347#[derive(Debug, Clone)]
348pub struct McpServerStatusSnapshot {
349    pub server_infos: HashMap<String, McpServerInfo>,
350    pub tools_by_server: HashMap<String, HashMap<String, Tool>>,
351    pub resources: HashMap<String, Vec<Resource>>,
352    pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
353    pub auth_statuses: HashMap<String, McpAuthStatus>,
354    pub server_names: Vec<String>,
355}
356
357pub async fn collect_mcp_server_status_snapshot_with_detail(
358    config: &McpConfig,
359    auth: Option<&CodexAuth>,
360    submit_id: String,
361    runtime_context: McpRuntimeContext,
362    codex_apps_tools_cache: ConnectorRuntimeManager<ToolInfo>,
363    tool_catalog_cache: crate::McpToolCatalogCache,
364    detail: McpSnapshotDetail,
365) -> McpServerStatusSnapshot {
366    let mcp_servers = effective_mcp_servers(config, auth);
367    let tool_plugin_provenance = tool_plugin_provenance(config);
368    if mcp_servers.is_empty() {
369        return McpServerStatusSnapshot {
370            server_infos: HashMap::new(),
371            tools_by_server: HashMap::new(),
372            resources: HashMap::new(),
373            resource_templates: HashMap::new(),
374            auth_statuses: HashMap::new(),
375            server_names: Vec::new(),
376        };
377    }
378
379    let auth_status_entries = compute_auth_statuses(
380        mcp_servers.iter(),
381        config.mcp_oauth_credentials_store_mode,
382        config.auth_keyring_backend_kind,
383        auth,
384        &runtime_context,
385    )
386    .await;
387
388    let server_names = mcp_servers.keys().cloned().collect();
389
390    let cancel_token = CancellationToken::new();
391    let mcp_connection_manager = McpConnectionManager::new(
392        &mcp_servers,
393        config.mcp_oauth_credentials_store_mode,
394        config.auth_keyring_backend_kind,
395        &config.approval_policy,
396        submit_id,
397        /*tx_event*/ None,
398        cancel_token.clone(),
399        PermissionProfile::default(),
400        runtime_context,
401        config.codex_home.clone(),
402        codex_apps_tools_cache,
403        tool_catalog_cache,
404        connector_runtime_context_key(auth),
405        config.prefix_mcp_tool_names,
406        config.client_elicitation_capability.clone(),
407        /*supports_openai_form_elicitation*/ false,
408        tool_plugin_provenance,
409        auth,
410        /*codex_apps_auth_manager*/ None,
411        /*elicitation_reviewer*/ None,
412        /*elicitation_lifecycle*/ None,
413        crate::elicitation::ElicitationRequestRouter::default(),
414    )
415    .await;
416
417    let snapshot = collect_mcp_server_status_snapshot_from_manager(
418        &mcp_connection_manager,
419        auth_status_entries,
420        server_names,
421        detail,
422    )
423    .await;
424
425    cancel_token.cancel();
426
427    snapshot
428}
429
430/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
431/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
432/// name we expose to the model by replacing any disallowed character with `_`.
433pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
434    let mut sanitized = String::with_capacity(name.len());
435    for c in name.chars() {
436        if c.is_ascii_alphanumeric() || c == '_' {
437            sanitized.push(c);
438        } else {
439            sanitized.push('_');
440        }
441    }
442
443    if sanitized.is_empty() {
444        "_".to_string()
445    } else {
446        sanitized
447    }
448}
449
450fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
451    match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
452        Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
453        Ok(_) => None,
454        Err(env::VarError::NotPresent) => None,
455        Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
456    }
457}
458
459fn normalize_codex_apps_base_url(base_url: &str) -> String {
460    let mut base_url = base_url.trim_end_matches('/').to_string();
461    if (base_url.starts_with("https://chatgpt.com")
462        || base_url.starts_with("https://chat.openai.com"))
463        && !base_url.contains("/backend-api")
464    {
465        base_url = format!("{base_url}/backend-api");
466    }
467    base_url
468}
469
470fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
471    let base_url = normalize_codex_apps_base_url(base_url);
472    let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") {
473        base_url
474    } else {
475        format!("{base_url}/api/codex")
476    };
477    format!("{base_url}/ps/mcp")
478}
479
480pub fn codex_apps_mcp_server_config(
481    chatgpt_base_url: &str,
482    apps_mcp_product_sku: Option<&str>,
483    originator: Option<&str>,
484) -> McpServerConfig {
485    mcp_server_config_for_url(
486        codex_apps_mcp_url_for_base_url(chatgpt_base_url),
487        apps_mcp_product_sku,
488        originator,
489        McpServerAuth::ChatGpt,
490    )
491}
492
493/// Builds the ChatGPT-hosted plugin runtime served by plugin-service.
494pub fn hosted_plugin_runtime_mcp_server_config(
495    chatgpt_base_url: &str,
496    apps_mcp_product_sku: Option<&str>,
497    originator: Option<&str>,
498) -> McpServerConfig {
499    codex_apps_mcp_server_config(chatgpt_base_url, apps_mcp_product_sku, originator)
500}
501
502fn mcp_server_config_for_url(
503    url: String,
504    apps_mcp_product_sku: Option<&str>,
505    originator: Option<&str>,
506    auth_mode: McpServerAuth,
507) -> McpServerConfig {
508    let product_sku = apps_mcp_product_sku.unwrap_or(DEFAULT_CODEX_APPS_MCP_PRODUCT_SKU);
509    let mut http_headers =
510        HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())]);
511    if let Some(originator) = originator {
512        http_headers.insert("originator".to_string(), originator.to_string());
513    }
514
515    McpServerConfig {
516        transport: McpServerTransportConfig::StreamableHttp {
517            url,
518            bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(),
519            http_headers: Some(http_headers),
520            env_http_headers: None,
521        },
522        auth: auth_mode,
523        environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
524        enabled: true,
525        required: false,
526        supports_parallel_tool_calls: false,
527        disabled_reason: None,
528        startup_timeout_sec: Some(Duration::from_secs(30)),
529        tool_timeout_sec: None,
530        default_tools_approval_mode: None,
531        enabled_tools: None,
532        disabled_tools: None,
533        scopes: None,
534        oauth: None,
535        oauth_resource: None,
536        tools: HashMap::new(),
537    }
538}
539
540fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option<Tool> {
541    match serde_json::to_value(tool) {
542        Ok(value) => match Tool::from_mcp_value(value) {
543            Ok(tool) => Some(tool),
544            Err(err) => {
545                tracing::warn!("Failed to convert MCP tool '{name}': {err}");
546                None
547            }
548        },
549        Err(err) => {
550            tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
551            None
552        }
553    }
554}
555
556fn auth_statuses_from_entries(
557    auth_status_entries: &HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
558) -> HashMap<String, McpAuthStatus> {
559    auth_status_entries
560        .iter()
561        .map(|(name, entry)| (name.clone(), McpAuthStatus::from(entry.auth_state)))
562        .collect::<HashMap<_, _>>()
563}
564
565fn convert_mcp_resources(
566    resources: HashMap<String, Vec<rmcp::model::Resource>>,
567) -> HashMap<String, Vec<Resource>> {
568    resources
569        .into_iter()
570        .map(|(name, resources)| {
571            let resources = resources
572                .into_iter()
573                .filter_map(|resource| match serde_json::to_value(resource) {
574                    Ok(value) => match Resource::from_mcp_value(value.clone()) {
575                        Ok(resource) => Some(resource),
576                        Err(err) => {
577                            let (uri, resource_name) = match value {
578                                Value::Object(obj) => (
579                                    obj.get("uri")
580                                        .and_then(|v| v.as_str().map(ToString::to_string)),
581                                    obj.get("name")
582                                        .and_then(|v| v.as_str().map(ToString::to_string)),
583                                ),
584                                _ => (None, None),
585                            };
586
587                            tracing::warn!(
588                                "Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}"
589                            );
590                            None
591                        }
592                    },
593                    Err(err) => {
594                        tracing::warn!("Failed to serialize MCP resource: {err}");
595                        None
596                    }
597                })
598                .collect::<Vec<_>>();
599            (name, resources)
600        })
601        .collect::<HashMap<_, _>>()
602}
603
604fn convert_mcp_resource_templates(
605    resource_templates: HashMap<String, Vec<rmcp::model::ResourceTemplate>>,
606) -> HashMap<String, Vec<ResourceTemplate>> {
607    resource_templates
608        .into_iter()
609        .map(|(name, templates)| {
610            let templates = templates
611                .into_iter()
612                .filter_map(|template| match serde_json::to_value(template) {
613                    Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) {
614                        Ok(template) => Some(template),
615                        Err(err) => {
616                            let (uri_template, template_name) = match value {
617                                Value::Object(obj) => (
618                                    obj.get("uriTemplate")
619                                        .or_else(|| obj.get("uri_template"))
620                                        .and_then(|v| v.as_str().map(ToString::to_string)),
621                                    obj.get("name")
622                                        .and_then(|v| v.as_str().map(ToString::to_string)),
623                                ),
624                                _ => (None, None),
625                            };
626
627                            tracing::warn!(
628                                "Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}"
629                            );
630                            None
631                        }
632                    },
633                    Err(err) => {
634                        tracing::warn!("Failed to serialize MCP resource template: {err}");
635                        None
636                    }
637                })
638                .collect::<Vec<_>>();
639            (name, templates)
640        })
641        .collect::<HashMap<_, _>>()
642}
643
644async fn collect_mcp_server_status_snapshot_from_manager(
645    mcp_connection_manager: &McpConnectionManager,
646    auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
647    server_names: Vec<String>,
648    detail: McpSnapshotDetail,
649) -> McpServerStatusSnapshot {
650    let ((server_infos, tools), resources, resource_templates) = tokio::join!(
651        async {
652            let server_infos = mcp_connection_manager.list_available_server_infos().await;
653            let tools = mcp_connection_manager.list_all_tools().await;
654            (server_infos, tools)
655        },
656        async {
657            if detail.include_resources() {
658                mcp_connection_manager.list_all_resources(|_| true).await
659            } else {
660                HashMap::new()
661            }
662        },
663        async {
664            if detail.include_resources() {
665                mcp_connection_manager
666                    .list_all_resource_templates(|_| true)
667                    .await
668            } else {
669                HashMap::new()
670            }
671        },
672    );
673
674    let mut tools_by_server = HashMap::<String, HashMap<String, Tool>>::new();
675    for tool_info in tools {
676        let raw_tool_name = tool_info.tool.name.to_string();
677        let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else {
678            continue;
679        };
680        let tool_name = tool.name.clone();
681        tools_by_server
682            .entry(tool_info.server_name)
683            .or_default()
684            .insert(tool_name, tool);
685    }
686
687    McpServerStatusSnapshot {
688        server_infos,
689        tools_by_server,
690        resources: convert_mcp_resources(resources),
691        resource_templates: convert_mcp_resource_templates(resource_templates),
692        auth_statuses: auth_statuses_from_entries(&auth_status_entries),
693        server_names,
694    }
695}
696
697#[cfg(test)]
698#[path = "mod_tests.rs"]
699pub(crate) mod tests;