mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## Why **In short:** this PR resolves already-discovered MCP registrations. It does not read selected plugins or discover their MCP servers. The resolved MCP catalog currently builds config and auto-discovered plugin registrations before runtime contributors are applied. A thread-selected plugin needs a distinct precedence tier in that same initial resolution pass: otherwise a disabled lower-precedence winner can leave stale name-level state behind, and the winning MCP tools cannot be attributed to the selected package reliably. This PR adds that catalog boundary before executor discovery is connected. ## What changed - Added an explicit selected-plugin registration tier between auto-discovered plugins and explicit config. - Collected selected-plugin contributions before the initial catalog build, while leaving compatibility and generic extension overlays in their existing runtime phase. - Retained the winning plugin ID and display name directly on plugin-owned catalog registrations. - Derived MCP tool provenance from the winning catalog entry instead of joining against local-only plugin summaries. - Retained the winning selected server's tool approval policy in the running connection manager, so a selected registration cannot inherit approval behavior from a losing local plugin. - Kept remembered approval session-scoped for selected plugins until there is an authority-aware persistence contract; Codex will not write approval back to an unrelated local plugin. - Preserved existing name-level disabled vetoes for discovered plugins and config, while keeping a selected package's own disabled registration scoped to that registration. - Preserved deterministic selection order and existing config, compatibility, and extension precedence. The resulting order is: ```text auto-discovered plugin < selected plugin < explicit config < compatibility registration < extension overlay ``` ## Behavior and scope This is a catalog and provenance change only. No production host contributes selected-plugin MCP registrations yet, so existing local MCP behavior remains unchanged. The stacked follow-up, #27870, installs the executor plugin provider that produces these registrations. App-server activation remains a separate final step. ## Verification Focused tests cover precedence, deterministic selected-plugin conflicts, disabled-veto behavior across catalog phases, managed requirements before selected-plugin resolution, winning-server approval policy, and attribution when local and selected packages share an ID or server name. CI owns execution of the test suite.
661 lines
23 KiB
Rust
661 lines
23 KiB
Rust
pub use auth::McpAuthStatusEntry;
|
|
pub use auth::McpOAuthLoginConfig;
|
|
pub use auth::McpOAuthLoginSupport;
|
|
pub use auth::McpOAuthScopesSource;
|
|
pub use auth::ResolvedMcpOAuthScopes;
|
|
pub use auth::compute_auth_statuses;
|
|
pub use auth::discover_supported_scopes;
|
|
pub use auth::oauth_login_support;
|
|
pub use auth::resolve_oauth_scopes;
|
|
pub use auth::should_retry_without_scopes;
|
|
|
|
pub(crate) mod auth;
|
|
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use async_channel::unbounded;
|
|
use codex_config::Constrained;
|
|
use codex_config::McpServerConfig;
|
|
use codex_config::McpServerTransportConfig;
|
|
use codex_config::types::AppToolApproval;
|
|
use codex_config::types::AuthKeyringBackendKind;
|
|
use codex_config::types::OAuthCredentialsStoreMode;
|
|
use codex_login::CodexAuth;
|
|
use codex_plugin::PluginCapabilitySummary;
|
|
use codex_protocol::mcp::McpServerInfo;
|
|
use codex_protocol::mcp::Resource;
|
|
use codex_protocol::mcp::ResourceTemplate;
|
|
use codex_protocol::mcp::Tool;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::McpAuthStatus;
|
|
use rmcp::model::ElicitationCapability;
|
|
use rmcp::model::ReadResourceRequestParams;
|
|
use rmcp::model::ReadResourceResult;
|
|
use serde_json::Value;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::ResolvedMcpCatalog;
|
|
use crate::codex_apps::codex_apps_tools_cache_key;
|
|
use crate::connection_manager::McpConnectionManager;
|
|
use crate::runtime::McpRuntimeContext;
|
|
use crate::server::EffectiveMcpServer;
|
|
|
|
pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
|
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
|
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
|
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum McpSnapshotDetail {
|
|
#[default]
|
|
Full,
|
|
ToolsAndAuthOnly,
|
|
}
|
|
|
|
impl McpSnapshotDetail {
|
|
fn include_resources(self) -> bool {
|
|
matches!(self, Self::Full)
|
|
}
|
|
}
|
|
|
|
pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
|
|
sanitize_responses_api_tool_name(&format!(
|
|
"{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
|
|
))
|
|
}
|
|
|
|
/// Returns true when MCP permission prompts should resolve as approved instead
|
|
/// of being shown to the user.
|
|
pub fn mcp_permission_prompt_is_auto_approved(
|
|
approval_policy: AskForApproval,
|
|
permission_profile: &PermissionProfile,
|
|
context: McpPermissionPromptAutoApproveContext,
|
|
) -> bool {
|
|
if context.tool_approval_mode == Some(AppToolApproval::Approve) {
|
|
return true;
|
|
}
|
|
|
|
if approval_policy != AskForApproval::Never {
|
|
return false;
|
|
}
|
|
|
|
match permission_profile {
|
|
PermissionProfile::Disabled | PermissionProfile::External { .. } => true,
|
|
PermissionProfile::Managed { file_system, .. } => {
|
|
file_system.to_sandbox_policy().has_full_disk_write_access()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub struct McpPermissionPromptAutoApproveContext {
|
|
pub tool_approval_mode: Option<AppToolApproval>,
|
|
}
|
|
|
|
/// MCP runtime settings derived from `codex_core::config::Config`.
|
|
///
|
|
/// This struct should contain only long-lived configuration values that the
|
|
/// `codex-mcp` crate needs to construct server transports, enforce MCP
|
|
/// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP
|
|
/// servers. Request-scoped or auth-scoped state should not be stored here;
|
|
/// thread those values explicitly into runtime entry points such as
|
|
/// [`effective_mcp_servers`] and snapshot collection helpers so config objects
|
|
/// do not go stale when auth changes.
|
|
#[derive(Debug, Clone)]
|
|
pub struct McpConfig {
|
|
/// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
|
|
pub chatgpt_base_url: String,
|
|
/// Optional product SKU forwarded to the host-owned apps MCP server.
|
|
pub apps_mcp_product_sku: Option<String>,
|
|
/// Codex home directory used for MCP OAuth state and app-tool cache files.
|
|
pub codex_home: PathBuf,
|
|
/// Preferred credential store for MCP OAuth tokens.
|
|
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
|
|
/// Backend used when MCP OAuth storage is configured for keyring-backed persistence.
|
|
pub auth_keyring_backend_kind: AuthKeyringBackendKind,
|
|
/// Optional fixed localhost callback port for MCP OAuth login.
|
|
pub mcp_oauth_callback_port: Option<u16>,
|
|
/// Optional OAuth redirect URI override for MCP login.
|
|
pub mcp_oauth_callback_url: Option<String>,
|
|
/// Whether skill MCP dependency installation prompts are enabled.
|
|
pub skill_mcp_dependency_install_enabled: bool,
|
|
/// Approval policy used for MCP tool calls and MCP elicitation requests.
|
|
pub approval_policy: Constrained<AskForApproval>,
|
|
/// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution.
|
|
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
|
/// Whether to use legacy Landlock behavior in the MCP sandbox state.
|
|
pub use_legacy_landlock: bool,
|
|
/// Whether the app MCP integration is enabled by config.
|
|
///
|
|
/// ChatGPT auth is checked separately before a materialized host-owned Apps
|
|
/// server can be used.
|
|
pub apps_enabled: bool,
|
|
/// Whether model-visible MCP tool namespaces should keep the legacy
|
|
/// `mcp__` prefix.
|
|
pub prefix_mcp_tool_names: bool,
|
|
/// Client-side elicitation capabilities advertised during MCP initialization.
|
|
pub client_elicitation_capability: ElicitationCapability,
|
|
/// Resolved MCP registrations keyed by logical server name.
|
|
pub mcp_server_catalog: ResolvedMcpCatalog,
|
|
/// Plugin metadata used to attribute connector tools to plugin display names.
|
|
/// MCP registrations retain their own package attribution in the catalog.
|
|
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ToolPluginProvenance {
|
|
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
|
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
|
|
plugin_ids_by_mcp_server_name: HashMap<String, String>,
|
|
selected_plugin_mcp_server_names: HashSet<String>,
|
|
}
|
|
|
|
impl ToolPluginProvenance {
|
|
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
|
|
self.plugin_display_names_by_connector_id
|
|
.get(connector_id)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
|
|
self.plugin_display_names_by_mcp_server_name
|
|
.get(server_name)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> {
|
|
self.plugin_ids_by_mcp_server_name
|
|
.get(server_name)
|
|
.map(String::as_str)
|
|
}
|
|
|
|
pub(crate) fn is_selected_plugin_mcp_server(&self, server_name: &str) -> bool {
|
|
self.selected_plugin_mcp_server_names.contains(server_name)
|
|
}
|
|
|
|
fn from_config(config: &McpConfig) -> Self {
|
|
let mut tool_plugin_provenance = Self::default();
|
|
for plugin in &config.plugin_capability_summaries {
|
|
for connector_id in &plugin.app_connector_ids {
|
|
tool_plugin_provenance
|
|
.plugin_display_names_by_connector_id
|
|
.entry(connector_id.0.clone())
|
|
.or_default()
|
|
.push(plugin.display_name.clone());
|
|
}
|
|
}
|
|
|
|
for (server_name, attribution) in config
|
|
.mcp_server_catalog
|
|
.plugin_attributions_by_server_name()
|
|
{
|
|
tool_plugin_provenance
|
|
.plugin_display_names_by_mcp_server_name
|
|
.insert(
|
|
server_name.clone(),
|
|
vec![attribution.display_name().to_string()],
|
|
);
|
|
tool_plugin_provenance
|
|
.plugin_ids_by_mcp_server_name
|
|
.insert(server_name, attribution.plugin_id().to_string());
|
|
}
|
|
tool_plugin_provenance
|
|
.selected_plugin_mcp_server_names
|
|
.extend(
|
|
config
|
|
.mcp_server_catalog
|
|
.selected_plugin_server_names()
|
|
.map(str::to_string),
|
|
);
|
|
|
|
for plugin_names in tool_plugin_provenance
|
|
.plugin_display_names_by_connector_id
|
|
.values_mut()
|
|
.chain(
|
|
tool_plugin_provenance
|
|
.plugin_display_names_by_mcp_server_name
|
|
.values_mut(),
|
|
)
|
|
{
|
|
plugin_names.sort_unstable();
|
|
plugin_names.dedup();
|
|
}
|
|
tool_plugin_provenance
|
|
}
|
|
}
|
|
|
|
pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool {
|
|
config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend)
|
|
}
|
|
|
|
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
|
|
config.mcp_server_catalog.configured_servers()
|
|
}
|
|
|
|
pub fn effective_mcp_servers(
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
) -> HashMap<String, EffectiveMcpServer> {
|
|
effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth)
|
|
}
|
|
|
|
/// Converts a materialized server map to its auth-gated runtime view.
|
|
///
|
|
/// Compatibility built-ins and extension overlays must already be reflected in
|
|
/// `configured_servers`; this function does not synthesize missing servers.
|
|
pub fn effective_mcp_servers_from_configured(
|
|
configured_servers: HashMap<String, McpServerConfig>,
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
) -> HashMap<String, EffectiveMcpServer> {
|
|
let mut servers = configured_servers
|
|
.into_iter()
|
|
.map(|(name, server)| (name, EffectiveMcpServer::configured(server)))
|
|
.collect::<HashMap<_, _>>();
|
|
if !host_owned_codex_apps_enabled(config, auth) {
|
|
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
|
}
|
|
servers
|
|
}
|
|
|
|
pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
|
|
ToolPluginProvenance::from_config(config)
|
|
}
|
|
|
|
pub async fn read_mcp_resource(
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
runtime_context: McpRuntimeContext,
|
|
server: &str,
|
|
uri: &str,
|
|
) -> anyhow::Result<ReadResourceResult> {
|
|
let mut mcp_servers = effective_mcp_servers(config, auth);
|
|
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth);
|
|
mcp_servers.retain(|name, _| name == server);
|
|
let auth_statuses = compute_auth_statuses(
|
|
mcp_servers.iter(),
|
|
config.mcp_oauth_credentials_store_mode,
|
|
config.auth_keyring_backend_kind,
|
|
auth,
|
|
)
|
|
.await;
|
|
let (tx_event, rx_event) = unbounded();
|
|
drop(rx_event);
|
|
let cancel_token = CancellationToken::new();
|
|
let manager = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
config.mcp_oauth_credentials_store_mode,
|
|
config.auth_keyring_backend_kind,
|
|
auth_statuses,
|
|
&config.approval_policy,
|
|
String::new(),
|
|
tx_event,
|
|
cancel_token.clone(),
|
|
PermissionProfile::default(),
|
|
runtime_context,
|
|
config.codex_home.clone(),
|
|
codex_apps_tools_cache_key(auth),
|
|
host_owned_codex_apps_enabled,
|
|
config.prefix_mcp_tool_names,
|
|
config.client_elicitation_capability.clone(),
|
|
tool_plugin_provenance(config),
|
|
auth,
|
|
/*elicitation_reviewer*/ None,
|
|
)
|
|
.await;
|
|
|
|
let result = manager
|
|
.read_resource(server, ReadResourceRequestParams::new(uri))
|
|
.await;
|
|
cancel_token.cancel();
|
|
result
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct McpServerStatusSnapshot {
|
|
pub server_infos: HashMap<String, McpServerInfo>,
|
|
pub tools_by_server: HashMap<String, HashMap<String, Tool>>,
|
|
pub resources: HashMap<String, Vec<Resource>>,
|
|
pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
|
|
pub auth_statuses: HashMap<String, McpAuthStatus>,
|
|
pub server_names: Vec<String>,
|
|
}
|
|
|
|
pub async fn collect_mcp_server_status_snapshot_with_detail(
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
submit_id: String,
|
|
runtime_context: McpRuntimeContext,
|
|
detail: McpSnapshotDetail,
|
|
) -> McpServerStatusSnapshot {
|
|
let mcp_servers = effective_mcp_servers(config, auth);
|
|
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth);
|
|
let tool_plugin_provenance = tool_plugin_provenance(config);
|
|
if mcp_servers.is_empty() {
|
|
return McpServerStatusSnapshot {
|
|
server_infos: HashMap::new(),
|
|
tools_by_server: HashMap::new(),
|
|
resources: HashMap::new(),
|
|
resource_templates: HashMap::new(),
|
|
auth_statuses: HashMap::new(),
|
|
server_names: Vec::new(),
|
|
};
|
|
}
|
|
|
|
let auth_status_entries = compute_auth_statuses(
|
|
mcp_servers.iter(),
|
|
config.mcp_oauth_credentials_store_mode,
|
|
config.auth_keyring_backend_kind,
|
|
auth,
|
|
)
|
|
.await;
|
|
|
|
let server_names = mcp_servers.keys().cloned().collect();
|
|
|
|
let (tx_event, rx_event) = unbounded();
|
|
drop(rx_event);
|
|
|
|
let cancel_token = CancellationToken::new();
|
|
let mcp_connection_manager = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
config.mcp_oauth_credentials_store_mode,
|
|
config.auth_keyring_backend_kind,
|
|
auth_status_entries.clone(),
|
|
&config.approval_policy,
|
|
submit_id,
|
|
tx_event,
|
|
cancel_token.clone(),
|
|
PermissionProfile::default(),
|
|
runtime_context,
|
|
config.codex_home.clone(),
|
|
codex_apps_tools_cache_key(auth),
|
|
host_owned_codex_apps_enabled,
|
|
config.prefix_mcp_tool_names,
|
|
config.client_elicitation_capability.clone(),
|
|
tool_plugin_provenance,
|
|
auth,
|
|
/*elicitation_reviewer*/ None,
|
|
)
|
|
.await;
|
|
|
|
let snapshot = collect_mcp_server_status_snapshot_from_manager(
|
|
&mcp_connection_manager,
|
|
auth_status_entries,
|
|
server_names,
|
|
detail,
|
|
)
|
|
.await;
|
|
|
|
cancel_token.cancel();
|
|
|
|
snapshot
|
|
}
|
|
|
|
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
|
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
|
/// name we expose to the model by replacing any disallowed character with `_`.
|
|
pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
|
|
let mut sanitized = String::with_capacity(name.len());
|
|
for c in name.chars() {
|
|
if c.is_ascii_alphanumeric() || c == '_' {
|
|
sanitized.push(c);
|
|
} else {
|
|
sanitized.push('_');
|
|
}
|
|
}
|
|
|
|
if sanitized.is_empty() {
|
|
"_".to_string()
|
|
} else {
|
|
sanitized
|
|
}
|
|
}
|
|
|
|
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
|
|
match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
|
|
Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
|
Ok(_) => None,
|
|
Err(env::VarError::NotPresent) => None,
|
|
Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
|
}
|
|
}
|
|
|
|
fn normalize_codex_apps_base_url(base_url: &str) -> String {
|
|
let mut base_url = base_url.trim_end_matches('/').to_string();
|
|
if (base_url.starts_with("https://chatgpt.com")
|
|
|| base_url.starts_with("https://chat.openai.com"))
|
|
&& !base_url.contains("/backend-api")
|
|
{
|
|
base_url = format!("{base_url}/backend-api");
|
|
}
|
|
base_url
|
|
}
|
|
|
|
fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
|
|
let base_url = normalize_codex_apps_base_url(base_url);
|
|
let (base_url, default_path) = if base_url.contains("/backend-api") {
|
|
(base_url, "wham/apps")
|
|
} else if base_url.contains("/api/codex") {
|
|
(base_url, "apps")
|
|
} else {
|
|
(format!("{base_url}/api/codex"), "apps")
|
|
};
|
|
format!("{base_url}/{default_path}")
|
|
}
|
|
|
|
pub fn codex_apps_mcp_server_config(
|
|
chatgpt_base_url: &str,
|
|
apps_mcp_product_sku: Option<&str>,
|
|
) -> McpServerConfig {
|
|
mcp_server_config_for_url(
|
|
codex_apps_mcp_url_for_base_url(chatgpt_base_url),
|
|
apps_mcp_product_sku,
|
|
)
|
|
}
|
|
|
|
/// Builds the ChatGPT-hosted plugin runtime served by plugin-service.
|
|
pub fn hosted_plugin_runtime_mcp_server_config(
|
|
chatgpt_base_url: &str,
|
|
apps_mcp_product_sku: Option<&str>,
|
|
) -> McpServerConfig {
|
|
let base_url = normalize_codex_apps_base_url(chatgpt_base_url);
|
|
let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") {
|
|
base_url
|
|
} else {
|
|
format!("{base_url}/api/codex")
|
|
};
|
|
mcp_server_config_for_url(format!("{base_url}/ps/mcp"), apps_mcp_product_sku)
|
|
}
|
|
|
|
fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) -> McpServerConfig {
|
|
let http_headers = apps_mcp_product_sku.map(|product_sku| {
|
|
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
|
|
});
|
|
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url,
|
|
bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(),
|
|
http_headers,
|
|
env_http_headers: None,
|
|
},
|
|
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(Duration::from_secs(30)),
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option<Tool> {
|
|
match serde_json::to_value(tool) {
|
|
Ok(value) => match Tool::from_mcp_value(value) {
|
|
Ok(tool) => Some(tool),
|
|
Err(err) => {
|
|
tracing::warn!("Failed to convert MCP tool '{name}': {err}");
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn auth_statuses_from_entries(
|
|
auth_status_entries: &HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
|
|
) -> HashMap<String, McpAuthStatus> {
|
|
auth_status_entries
|
|
.iter()
|
|
.map(|(name, entry)| (name.clone(), entry.auth_status))
|
|
.collect::<HashMap<_, _>>()
|
|
}
|
|
|
|
fn convert_mcp_resources(
|
|
resources: HashMap<String, Vec<rmcp::model::Resource>>,
|
|
) -> HashMap<String, Vec<Resource>> {
|
|
resources
|
|
.into_iter()
|
|
.map(|(name, resources)| {
|
|
let resources = resources
|
|
.into_iter()
|
|
.filter_map(|resource| match serde_json::to_value(resource) {
|
|
Ok(value) => match Resource::from_mcp_value(value.clone()) {
|
|
Ok(resource) => Some(resource),
|
|
Err(err) => {
|
|
let (uri, resource_name) = match value {
|
|
Value::Object(obj) => (
|
|
obj.get("uri")
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
obj.get("name")
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
),
|
|
_ => (None, None),
|
|
};
|
|
|
|
tracing::warn!(
|
|
"Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!("Failed to serialize MCP resource: {err}");
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
(name, resources)
|
|
})
|
|
.collect::<HashMap<_, _>>()
|
|
}
|
|
|
|
fn convert_mcp_resource_templates(
|
|
resource_templates: HashMap<String, Vec<rmcp::model::ResourceTemplate>>,
|
|
) -> HashMap<String, Vec<ResourceTemplate>> {
|
|
resource_templates
|
|
.into_iter()
|
|
.map(|(name, templates)| {
|
|
let templates = templates
|
|
.into_iter()
|
|
.filter_map(|template| match serde_json::to_value(template) {
|
|
Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) {
|
|
Ok(template) => Some(template),
|
|
Err(err) => {
|
|
let (uri_template, template_name) = match value {
|
|
Value::Object(obj) => (
|
|
obj.get("uriTemplate")
|
|
.or_else(|| obj.get("uri_template"))
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
obj.get("name")
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
),
|
|
_ => (None, None),
|
|
};
|
|
|
|
tracing::warn!(
|
|
"Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!("Failed to serialize MCP resource template: {err}");
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
(name, templates)
|
|
})
|
|
.collect::<HashMap<_, _>>()
|
|
}
|
|
|
|
async fn collect_mcp_server_status_snapshot_from_manager(
|
|
mcp_connection_manager: &McpConnectionManager,
|
|
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
|
|
server_names: Vec<String>,
|
|
detail: McpSnapshotDetail,
|
|
) -> McpServerStatusSnapshot {
|
|
let (tools, resources, resource_templates) = tokio::join!(
|
|
mcp_connection_manager.list_all_tools(),
|
|
async {
|
|
if detail.include_resources() {
|
|
mcp_connection_manager.list_all_resources().await
|
|
} else {
|
|
HashMap::new()
|
|
}
|
|
},
|
|
async {
|
|
if detail.include_resources() {
|
|
mcp_connection_manager.list_all_resource_templates().await
|
|
} else {
|
|
HashMap::new()
|
|
}
|
|
},
|
|
);
|
|
let server_infos = mcp_connection_manager.list_available_server_infos().await;
|
|
|
|
let mut tools_by_server = HashMap::<String, HashMap<String, Tool>>::new();
|
|
for tool_info in tools {
|
|
let raw_tool_name = tool_info.tool.name.to_string();
|
|
let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else {
|
|
continue;
|
|
};
|
|
let tool_name = tool.name.clone();
|
|
tools_by_server
|
|
.entry(tool_info.server_name)
|
|
.or_default()
|
|
.insert(tool_name, tool);
|
|
}
|
|
|
|
McpServerStatusSnapshot {
|
|
server_infos,
|
|
tools_by_server,
|
|
resources: convert_mcp_resources(resources),
|
|
resource_templates: convert_mcp_resource_templates(resource_templates),
|
|
auth_statuses: auth_statuses_from_entries(&auth_status_entries),
|
|
server_names,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_tests.rs"]
|
|
mod tests;
|