use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::LazyLock; use std::time::Duration; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use crate::app_error::AppCommandError; const MARKETPLACE_OFFICIAL: &str = "official_registry"; const MARKETPLACE_SMITHERY: &str = "smithery"; static MARKETPLACE_HTTP_CLIENT: LazyLock> = LazyLock::new(|| { reqwest::Client::builder() .connect_timeout(Duration::from_secs(8)) .timeout(Duration::from_secs(20)) .user_agent("codeg-mcp-market/1.0") .build() .map_err(|e| format!("failed to initialize marketplace HTTP client: {e}")) }); fn mcp_invalid_input(message: impl Into) -> AppCommandError { AppCommandError::invalid_input(message) } fn mcp_not_found(message: impl Into) -> AppCommandError { AppCommandError::not_found(message) } fn mcp_configuration_invalid(message: impl Into) -> AppCommandError { AppCommandError::configuration_invalid(message) } fn mcp_network(message: impl Into) -> AppCommandError { AppCommandError::network(message) } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum McpAppType { ClaudeCode, Codex, Gemini, OpenClaw, OpenCode, Cline, } #[derive(Debug, Clone, Serialize)] pub struct LocalMcpServer { pub id: String, pub spec: Value, pub apps: Vec, } #[derive(Debug, Clone, Serialize)] pub struct McpMarketplaceProvider { pub id: String, pub name: String, pub description: String, } #[derive(Debug, Clone, Serialize)] pub struct McpMarketplaceItem { pub provider_id: String, pub server_id: String, pub name: String, pub description: String, pub homepage: Option, pub remote: bool, pub verified: bool, pub icon_url: Option, pub latest_version: Option, pub protocols: Vec, pub owner: Option, pub namespace: Option, pub downloads: Option, pub score: Option, pub is_deployed: Option, } #[derive(Debug, Clone, Serialize)] pub struct McpMarketplaceInstallParameter { pub key: String, pub label: String, pub description: Option, pub required: bool, pub secret: bool, pub kind: String, pub default_value: Option, pub placeholder: Option, pub enum_values: Vec, pub location: Option, } #[derive(Debug, Clone, Serialize)] pub struct McpMarketplaceInstallOption { pub id: String, pub protocol: String, pub label: String, pub description: Option, pub spec: Value, pub parameters: Vec, } #[derive(Debug, Clone, Serialize)] pub struct McpMarketplaceServerDetail { pub provider_id: String, pub server_id: String, pub name: String, pub description: String, pub homepage: Option, pub remote: bool, pub verified: bool, pub icon_url: Option, pub latest_version: Option, pub protocols: Vec, pub owner: Option, pub namespace: Option, pub downloads: Option, pub score: Option, pub is_deployed: Option, pub default_option_id: Option, pub install_options: Vec, pub spec: Value, } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_scan_local() -> Result, AppCommandError> { scan_local_servers() } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_list_marketplaces() -> Result, AppCommandError> { Ok(vec![ McpMarketplaceProvider { id: MARKETPLACE_OFFICIAL.to_string(), name: "Official MCP Registry".to_string(), description: "registry.modelcontextprotocol.io official MCP server registry" .to_string(), }, McpMarketplaceProvider { id: MARKETPLACE_SMITHERY.to_string(), name: "Smithery".to_string(), description: "smithery.ai MCP server marketplace".to_string(), }, ]) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_search_marketplace( provider_id: String, query: Option, limit: Option, ) -> Result, AppCommandError> { let q = query.unwrap_or_default(); let max = limit.unwrap_or(30).clamp(1, 100); match provider_id.as_str() { MARKETPLACE_OFFICIAL => search_official_registry(&q, max).await, MARKETPLACE_SMITHERY => search_smithery(&q, max).await, _ => Err(mcp_invalid_input(format!( "unsupported marketplace provider: {provider_id}" ))), } } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_get_marketplace_server_detail( provider_id: String, server_id: String, ) -> Result { match provider_id.as_str() { MARKETPLACE_OFFICIAL => { let detail = fetch_official_server_detail(&server_id).await?; let item = official_entry_to_item(&detail); let install_options = build_official_install_options(&detail.server)?; let default_option = select_default_install_option(&install_options); let spec = default_option .map(|item| item.spec.clone()) .ok_or_else(|| { mcp_not_found(format!( "official MCP server '{}' does not expose an installable transport", item.server_id )) })?; Ok(McpMarketplaceServerDetail { provider_id: MARKETPLACE_OFFICIAL.to_string(), server_id: item.server_id, name: item.name, description: item.description, homepage: item.homepage, remote: item.remote, verified: item.verified, icon_url: item.icon_url, latest_version: item.latest_version, protocols: item.protocols, owner: item.owner, namespace: item.namespace, downloads: item.downloads, score: item.score, is_deployed: item.is_deployed, default_option_id: default_option.map(|item| item.id.clone()), install_options, spec, }) } MARKETPLACE_SMITHERY => { let detail = fetch_smithery_server_detail(&server_id).await?; let summary = fetch_smithery_server_summary(&server_id).await.ok(); let install_options = build_smithery_install_options(&detail)?; let default_option = select_default_install_option(&install_options); let spec = default_option .map(|item| item.spec.clone()) .ok_or_else(|| { mcp_not_found(format!( "smithery server '{}' does not provide installable connection info", detail.qualified_name )) })?; Ok(McpMarketplaceServerDetail { provider_id: MARKETPLACE_SMITHERY.to_string(), server_id: detail.qualified_name.clone(), name: detail.display_name.clone(), description: detail .description .as_deref() .or_else(|| { summary .as_ref() .and_then(|item| item.description.as_deref()) }) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| "No description".to_string()), homepage: detail .homepage .as_deref() .or_else(|| summary.as_ref().and_then(|item| item.homepage.as_deref())) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), remote: detail.remote, verified: detail.verified || summary.as_ref().map(|item| item.verified).unwrap_or(false), icon_url: detail .icon_url .as_deref() .or_else(|| summary.as_ref().and_then(|item| item.icon_url.as_deref())) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), latest_version: None, protocols: collect_protocols_from_options(&install_options), owner: detail .owner .as_deref() .or_else(|| summary.as_ref().and_then(|item| item.owner.as_deref())) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), namespace: detail .namespace .as_deref() .or_else(|| summary.as_ref().and_then(|item| item.namespace.as_deref())) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), downloads: detail .use_count .or_else(|| summary.as_ref().and_then(|item| item.use_count)), score: detail .score .or_else(|| summary.as_ref().and_then(|item| item.score)), is_deployed: detail .is_deployed .or_else(|| summary.as_ref().and_then(|item| item.is_deployed)), default_option_id: default_option.map(|item| item.id.clone()), install_options, spec, }) } _ => Err(mcp_invalid_input(format!( "unsupported marketplace provider: {provider_id}" ))), } } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_install_from_marketplace( provider_id: String, server_id: String, apps: Vec, spec_override: Option, option_id: Option, protocol: Option, parameter_values: Option, ) -> Result { let normalized_apps = normalize_apps(apps); if normalized_apps.is_empty() { return Err(mcp_invalid_input("at least one target app is required")); } let selection = InstallSelection::new(option_id, protocol, parameter_values)?; let canonical_spec = if let Some(raw_spec) = spec_override.as_ref() { canonicalize_spec(raw_spec, "marketplace install override")? } else { match provider_id.as_str() { MARKETPLACE_OFFICIAL => { let detail = fetch_official_server_detail(&server_id).await?; resolve_official_install_spec_with_selection(&detail.server, &selection)? } MARKETPLACE_SMITHERY => { let detail = fetch_smithery_server_detail(&server_id).await?; resolve_smithery_install_spec_with_selection(&detail, &selection)? } _ => { return Err(mcp_invalid_input(format!( "unsupported marketplace provider: {provider_id}" ))); } } }; for app in &normalized_apps { upsert_server_for_app(*app, &server_id, &canonical_spec)?; } find_local_server(&server_id)?.ok_or_else(|| { mcp_configuration_invalid(format!( "installed server '{server_id}', but failed to load it from local configuration" )) }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_upsert_local_server( server_id: String, spec: Value, apps: Vec, ) -> Result { let canonical_spec = canonicalize_spec(&spec, "local MCP save")?; let target_apps = normalize_apps(apps); if target_apps.is_empty() { return Err(mcp_invalid_input("at least one target app is required")); } let target_set = target_apps.iter().copied().collect::>(); let all_apps = [ McpAppType::ClaudeCode, McpAppType::Codex, McpAppType::Gemini, McpAppType::OpenClaw, McpAppType::OpenCode, McpAppType::Cline, ]; for app in all_apps { if target_set.contains(&app) { upsert_server_for_app(app, &server_id, &canonical_spec)?; } else { let _ = remove_server_for_app(app, &server_id)?; } } find_local_server(&server_id)?.ok_or_else(|| { mcp_configuration_invalid(format!( "saved local MCP server '{server_id}', but failed to reload it" )) }) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_set_server_apps( server_id: String, apps: Vec, ) -> Result, AppCommandError> { let target_apps = normalize_apps(apps); let current = find_local_server(&server_id)? .ok_or_else(|| mcp_not_found(format!("local MCP server not found: {server_id}")))?; let target_set = target_apps.iter().copied().collect::>(); let current_set = current.apps.iter().copied().collect::>(); for app in current_set.difference(&target_set) { remove_server_for_app(*app, &server_id)?; } for app in target_set.difference(¤t_set) { upsert_server_for_app(*app, &server_id, ¤t.spec)?; } find_local_server(&server_id) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn mcp_remove_server( server_id: String, apps: Option>, ) -> Result { let target_apps = match apps { Some(selected) => normalize_apps(selected), None => vec![ McpAppType::ClaudeCode, McpAppType::Codex, McpAppType::Gemini, McpAppType::OpenClaw, McpAppType::OpenCode, McpAppType::Cline, ], }; if target_apps.is_empty() { return Ok(false); } let mut removed = false; for app in target_apps { removed |= remove_server_for_app(app, &server_id)?; } Ok(removed) } fn normalize_apps(apps: Vec) -> Vec { let mut seen = BTreeSet::new(); for app in apps { seen.insert(app); } seen.into_iter().collect() } #[derive(Debug, Clone)] struct InstallSelection { option_id: Option, protocol: Option, parameter_values: Map, } impl InstallSelection { fn new( option_id: Option, protocol: Option, parameter_values: Option, ) -> Result { let parsed = if let Some(raw) = parameter_values { let obj = raw .as_object() .ok_or_else(|| mcp_invalid_input("parameter_values must be a JSON object"))?; obj.clone() } else { Map::new() }; Ok(Self { option_id: option_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), protocol: protocol .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(normalize_protocol_value), parameter_values: parsed, }) } } fn normalize_protocol_value(raw: &str) -> String { match raw.trim() { "streamable-http" => "http".to_string(), other => other.to_string(), } } fn protocol_priority(protocol: &str) -> i32 { match normalize_protocol_value(protocol).as_str() { "stdio" => 0, "http" => 1, "sse" => 2, _ => 10, } } fn select_default_install_option( options: &[McpMarketplaceInstallOption], ) -> Option<&McpMarketplaceInstallOption> { options .iter() .min_by_key(|item| protocol_priority(&item.protocol)) } fn collect_protocols_from_options(options: &[McpMarketplaceInstallOption]) -> Vec { let mut seen = BTreeSet::new(); for option in options { seen.insert(normalize_protocol_value(&option.protocol)); } seen.into_iter().collect() } fn home_dir_or_default() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } fn codex_home_dir() -> PathBuf { let configured = std::env::var("CODEX_HOME").ok().and_then(|raw| { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); match configured { Some(value) => { if value == "~" { home_dir_or_default() } else if let Some(remain) = value.strip_prefix("~/") { home_dir_or_default().join(remain) } else { PathBuf::from(value) } } None => home_dir_or_default().join(".codex"), } } fn claude_config_path() -> PathBuf { home_dir_or_default().join(".claude.json") } fn codex_config_toml_path() -> PathBuf { codex_home_dir().join("config.toml") } fn opencode_config_path() -> PathBuf { home_dir_or_default() .join(".config") .join("opencode") .join("config.json") } fn gemini_config_path() -> PathBuf { home_dir_or_default().join(".gemini").join("settings.json") } fn openclaw_config_path() -> PathBuf { home_dir_or_default() .join(".openclaw") .join("openclaw.json") } fn cline_config_path() -> PathBuf { home_dir_or_default() .join(".cline") .join("data") .join("settings") .join("cline_mcp_settings.json") } fn read_json_file(path: &Path) -> Result { if !path.exists() { return Ok(json!({})); } let raw = fs::read_to_string(path).map_err(AppCommandError::io)?; serde_json::from_str::(&raw) .map_err(|e| mcp_configuration_invalid(format!("invalid JSON at {}: {e}", path.display()))) } fn write_json_file(path: &Path, value: &Value) -> Result<(), AppCommandError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(AppCommandError::io)?; } let serialized = serde_json::to_string_pretty(value).map_err(|e| { mcp_configuration_invalid(format!( "failed to serialize JSON for {}: {e}", path.display() )) })?; fs::write(path, format!("{serialized}\n")).map_err(AppCommandError::io) } fn read_codex_root_toml() -> Result { let path = codex_config_toml_path(); if !path.exists() { return Ok(toml::Value::Table(toml::map::Map::new())); } let raw = fs::read_to_string(&path).map_err(AppCommandError::io)?; let parsed = raw.parse::().map_err(|e| { mcp_configuration_invalid(format!("invalid TOML at {}: {e}", path.display())) })?; if !parsed.is_table() { return Err(mcp_configuration_invalid(format!( "invalid TOML root at {}: expected table", path.display() ))); } Ok(parsed) } fn write_codex_root_toml(root: &toml::Value) -> Result<(), AppCommandError> { let path = codex_config_toml_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(AppCommandError::io)?; } let serialized = toml::to_string_pretty(root).map_err(|e| { mcp_configuration_invalid(format!( "failed to serialize TOML for {}: {e}", path.display() )) })?; fs::write(&path, format!("{serialized}\n")).map_err(AppCommandError::io) } fn obj_as_string_map(value: Option<&Value>) -> Option> { let obj = value.and_then(Value::as_object)?; let mut output = Map::with_capacity(obj.len()); for (key, item) in obj { let Some(s) = item.as_str() else { continue; }; let trimmed = s.trim(); if trimmed.is_empty() { continue; } output.insert(key.to_string(), Value::String(trimmed.to_string())); } if output.is_empty() { None } else { Some(output) } } fn contains_unresolved_placeholder(value: &str) -> bool { value.contains('{') && value.contains('}') } fn marketplace_http_client() -> Result { match &*MARKETPLACE_HTTP_CLIENT { Ok(client) => Ok(client.clone()), Err(err) => Err(mcp_network(err.clone())), } } fn should_retry_http_status(status: reqwest::StatusCode) -> bool { status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() } fn format_market_network_error(context: &str, err: &reqwest::Error) -> String { if err.is_timeout() { return format!( "{context}: request timed out. Please check network/proxy settings and retry: {err}" ); } if err.is_connect() { return format!( "{context}: network connection failed. Please check network/proxy settings and retry: {err}" ); } format!("{context}: {err}") } async fn send_request_with_retry( context: &str, mut build: F, ) -> Result where F: FnMut() -> reqwest::RequestBuilder, { const MAX_ATTEMPTS: usize = 3; let mut last_error: Option = None; for attempt in 1..=MAX_ATTEMPTS { match build().send().await { Ok(response) => { if should_retry_http_status(response.status()) && attempt < MAX_ATTEMPTS { tokio::time::sleep(Duration::from_millis((attempt as u64) * 350)).await; continue; } return Ok(response); } Err(err) => { last_error = Some(format_market_network_error(context, &err)); if attempt < MAX_ATTEMPTS { tokio::time::sleep(Duration::from_millis((attempt as u64) * 350)).await; } } } } Err(mcp_network( last_error.unwrap_or_else(|| format!("{context}: request failed")), )) } async fn parse_json_response( response: reqwest::Response, context: &str, ) -> Result { let raw = response .text() .await .map_err(|e| mcp_network(format!("{context}: failed to read response body: {e}")))?; serde_json::from_str::(&raw) .map_err(|e| mcp_network(format!("{context}: invalid JSON response: {e}"))) } async fn parse_json_value_response( response: reqwest::Response, context: &str, ) -> Result { let raw = response .text() .await .map_err(|e| mcp_network(format!("{context}: failed to read response body: {e}")))?; serde_json::from_str::(&raw) .map_err(|e| mcp_network(format!("{context}: invalid JSON response: {e}"))) } fn canonicalize_spec(spec: &Value, source: &str) -> Result { let obj = spec .as_object() .ok_or_else(|| mcp_invalid_input(format!("{source}: MCP spec must be a JSON object")))?; let mut inferred_type = obj .get("type") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default() .to_string(); if inferred_type.is_empty() { if obj.get("command").is_some() { inferred_type = "stdio".to_string(); } else if obj.get("url").is_some() { inferred_type = "http".to_string(); } } if inferred_type == "streamable-http" { inferred_type = "http".to_string(); } let mut normalized = Map::new(); match inferred_type.as_str() { "stdio" => { let command = obj .get("command") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { mcp_invalid_input(format!( "{source}: stdio MCP spec requires a non-empty command" )) })?; normalized.insert("type".to_string(), Value::String("stdio".to_string())); normalized.insert("command".to_string(), Value::String(command.to_string())); if let Some(args) = obj.get("args").and_then(Value::as_array) { let values = args .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| Value::String(value.to_string())) .collect::>(); if !values.is_empty() { normalized.insert("args".to_string(), Value::Array(values)); } } if let Some(env) = obj_as_string_map(obj.get("env")) { normalized.insert("env".to_string(), Value::Object(env)); } if let Some(cwd) = obj .get("cwd") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { normalized.insert("cwd".to_string(), Value::String(cwd.to_string())); } } "http" | "sse" => { let url = obj .get("url") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { mcp_invalid_input(format!( "{source}: remote MCP spec requires a non-empty url" )) })?; normalized.insert("type".to_string(), Value::String(inferred_type)); normalized.insert("url".to_string(), Value::String(url.to_string())); if let Some(headers) = obj_as_string_map(obj.get("headers")) { normalized.insert("headers".to_string(), Value::Object(headers)); } } "local" => { return canonicalize_opencode_spec(spec, source); } "remote" => { return canonicalize_opencode_spec(spec, source); } _ => { return Err(mcp_invalid_input(format!( "{source}: unsupported MCP server type '{}'; expected stdio/http/sse", inferred_type ))); } } for (key, value) in obj { if normalized.contains_key(key) { continue; } if key == "type" || key == "command" || key == "args" || key == "env" || key == "cwd" || key == "url" || key == "headers" { continue; } if !value.is_null() { normalized.insert(key.clone(), value.clone()); } } Ok(Value::Object(normalized)) } fn canonicalize_opencode_spec(spec: &Value, source: &str) -> Result { let obj = spec.as_object().ok_or_else(|| { mcp_invalid_input(format!("{source}: OpenCode MCP spec must be a JSON object")) })?; let typ = obj .get("type") .and_then(Value::as_str) .map(str::trim) .unwrap_or("local"); match typ { "local" => { let mut converted = Map::new(); converted.insert("type".to_string(), Value::String("stdio".to_string())); if let Some(command) = obj.get("command") { if let Some(arr) = command.as_array() { let first = arr .first() .and_then(Value::as_str) .map(str::trim) .filter(|item| !item.is_empty()) .ok_or_else(|| { mcp_invalid_input(format!( "{source}: local MCP command array must include executable" )) })?; converted.insert("command".to_string(), Value::String(first.to_string())); if arr.len() > 1 { let args = arr[1..] .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|item| !item.is_empty()) .map(|item| Value::String(item.to_string())) .collect::>(); if !args.is_empty() { converted.insert("args".to_string(), Value::Array(args)); } } } else if let Some(raw) = command.as_str() { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(mcp_invalid_input(format!( "{source}: local MCP command must be non-empty" ))); } converted.insert("command".to_string(), Value::String(trimmed.to_string())); } } if let Some(env) = obj_as_string_map(obj.get("environment")) { converted.insert("env".to_string(), Value::Object(env)); } if let Some(cwd) = obj .get("cwd") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { converted.insert("cwd".to_string(), Value::String(cwd.to_string())); } canonicalize_spec(&Value::Object(converted), source) } "remote" => { let mut converted = Map::new(); let remote_type = obj .get("transport") .and_then(Value::as_str) .map(str::trim) .filter(|value| *value == "sse") .map(|_| "sse") .unwrap_or("http"); converted.insert("type".to_string(), Value::String(remote_type.to_string())); if let Some(url) = obj .get("url") .or_else(|| obj.get("deploymentUrl")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { converted.insert("url".to_string(), Value::String(url.to_string())); } if let Some(headers) = obj_as_string_map(obj.get("headers")) { converted.insert("headers".to_string(), Value::Object(headers)); } canonicalize_spec(&Value::Object(converted), source) } _ => canonicalize_spec(spec, source), } } fn canonical_to_opencode_spec(spec: &Value) -> Result { let canonical = canonicalize_spec(spec, "OpenCode conversion")?; let obj = canonical.as_object().ok_or_else(|| { mcp_invalid_input("OpenCode conversion: canonical spec must be an object") })?; let typ = obj.get("type").and_then(Value::as_str).unwrap_or("stdio"); let mut out = Map::new(); match typ { "stdio" => { let cmd = obj.get("command").and_then(Value::as_str).ok_or_else(|| { mcp_invalid_input("OpenCode conversion: stdio MCP spec missing command") })?; out.insert("type".to_string(), Value::String("local".to_string())); let mut command = vec![Value::String(cmd.to_string())]; if let Some(args) = obj.get("args").and_then(Value::as_array) { for arg in args { if let Some(raw) = arg.as_str() { let trimmed = raw.trim(); if !trimmed.is_empty() { command.push(Value::String(trimmed.to_string())); } } } } out.insert("command".to_string(), Value::Array(command)); if let Some(env) = obj_as_string_map(obj.get("env")) { out.insert("environment".to_string(), Value::Object(env)); } if let Some(cwd) = obj .get("cwd") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { out.insert("cwd".to_string(), Value::String(cwd.to_string())); } } "http" | "sse" => { let url = obj.get("url").and_then(Value::as_str).ok_or_else(|| { mcp_invalid_input("OpenCode conversion: remote MCP spec missing url") })?; out.insert("type".to_string(), Value::String("remote".to_string())); out.insert("url".to_string(), Value::String(url.to_string())); if typ == "sse" { out.insert("transport".to_string(), Value::String("sse".to_string())); } if let Some(headers) = obj_as_string_map(obj.get("headers")) { out.insert("headers".to_string(), Value::Object(headers)); } } _ => { return Err(mcp_invalid_input(format!( "OpenCode conversion: unsupported MCP type '{typ}'" ))); } } Ok(Value::Object(out)) } fn json_to_toml_value(value: &Value) -> Option { match value { Value::Null => None, Value::Bool(v) => Some(toml::Value::Boolean(*v)), Value::Number(v) => { if let Some(i) = v.as_i64() { Some(toml::Value::Integer(i)) } else { v.as_f64().map(toml::Value::Float) } } Value::String(v) => Some(toml::Value::String(v.clone())), Value::Array(values) => { let mut converted = Vec::with_capacity(values.len()); for item in values { let next = json_to_toml_value(item)?; converted.push(next); } Some(toml::Value::Array(converted)) } Value::Object(map) => { let mut table = toml::map::Map::new(); for (key, val) in map { let Some(next) = json_to_toml_value(val) else { continue; }; table.insert(key.clone(), next); } Some(toml::Value::Table(table)) } } } fn toml_to_json_value(value: &toml::Value) -> Value { match value { toml::Value::String(v) => Value::String(v.clone()), toml::Value::Integer(v) => Value::Number((*v).into()), toml::Value::Float(v) => serde_json::Number::from_f64(*v) .map(Value::Number) .unwrap_or(Value::Null), toml::Value::Boolean(v) => Value::Bool(*v), toml::Value::Datetime(v) => Value::String(v.to_string()), toml::Value::Array(values) => Value::Array(values.iter().map(toml_to_json_value).collect()), toml::Value::Table(table) => { let mut out = Map::new(); for (key, item) in table { out.insert(key.to_string(), toml_to_json_value(item)); } Value::Object(out) } } } fn codex_entry_to_canonical(id: &str, value: &toml::Value) -> Result { let table = value .as_table() .ok_or_else(|| mcp_invalid_input(format!("Codex MCP entry '{id}' must be a table")))?; let raw_type = table .get("type") .and_then(toml::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("stdio") .to_string(); let mut spec = Map::new(); spec.insert("type".to_string(), Value::String(raw_type.clone())); match raw_type.as_str() { "stdio" => { if let Some(command) = table .get("command") .and_then(toml::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { spec.insert("command".to_string(), Value::String(command.to_string())); } if let Some(args) = table.get("args").and_then(toml::Value::as_array) { let values = args .iter() .filter_map(toml::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| Value::String(value.to_string())) .collect::>(); if !values.is_empty() { spec.insert("args".to_string(), Value::Array(values)); } } if let Some(env) = table.get("env").and_then(toml::Value::as_table) { let mut env_map = Map::new(); for (key, value) in env { let Some(text) = value.as_str() else { continue; }; let trimmed = text.trim(); if trimmed.is_empty() { continue; } env_map.insert(key.to_string(), Value::String(trimmed.to_string())); } if !env_map.is_empty() { spec.insert("env".to_string(), Value::Object(env_map)); } } if let Some(cwd) = table .get("cwd") .and_then(toml::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { spec.insert("cwd".to_string(), Value::String(cwd.to_string())); } } "http" | "sse" | "streamable-http" => { if let Some(url) = table .get("url") .and_then(toml::Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { spec.insert("url".to_string(), Value::String(url.to_string())); } let headers_table = table .get("http_headers") .and_then(toml::Value::as_table) .or_else(|| table.get("headers").and_then(toml::Value::as_table)); if let Some(headers) = headers_table { let mut mapped = Map::new(); for (key, value) in headers { let Some(text) = value.as_str() else { continue; }; let trimmed = text.trim(); if trimmed.is_empty() { continue; } mapped.insert(key.to_string(), Value::String(trimmed.to_string())); } if !mapped.is_empty() { spec.insert("headers".to_string(), Value::Object(mapped)); } } } _ => { return Err(mcp_invalid_input(format!( "Codex MCP entry '{id}' has unsupported type '{raw_type}'" ))); } } for (key, value) in table { if key == "type" || key == "command" || key == "args" || key == "env" || key == "cwd" || key == "url" || key == "headers" || key == "http_headers" { continue; } spec.insert(key.to_string(), toml_to_json_value(value)); } canonicalize_spec(&Value::Object(spec), "Codex config") } fn canonical_to_codex_entry(spec: &Value) -> Result { let canonical = canonicalize_spec(spec, "Codex conversion")?; let obj = canonical .as_object() .ok_or_else(|| mcp_invalid_input("Codex conversion: canonical spec must be an object"))?; let typ = obj.get("type").and_then(Value::as_str).unwrap_or("stdio"); let mut table = toml::map::Map::new(); table.insert("type".to_string(), toml::Value::String(typ.to_string())); match typ { "stdio" => { let command = obj.get("command").and_then(Value::as_str).ok_or_else(|| { mcp_invalid_input("Codex conversion: stdio MCP spec missing command") })?; table.insert( "command".to_string(), toml::Value::String(command.to_string()), ); if let Some(args) = obj.get("args").and_then(Value::as_array) { let values = args .iter() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| toml::Value::String(value.to_string())) .collect::>(); if !values.is_empty() { table.insert("args".to_string(), toml::Value::Array(values)); } } if let Some(cwd) = obj .get("cwd") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { table.insert("cwd".to_string(), toml::Value::String(cwd.to_string())); } if let Some(env) = obj.get("env").and_then(Value::as_object) { let mut env_table = toml::map::Map::new(); for (key, value) in env { let Some(text) = value.as_str() else { continue; }; let trimmed = text.trim(); if trimmed.is_empty() { continue; } env_table.insert(key.to_string(), toml::Value::String(trimmed.to_string())); } if !env_table.is_empty() { table.insert("env".to_string(), toml::Value::Table(env_table)); } } } "http" | "sse" => { let url = obj.get("url").and_then(Value::as_str).ok_or_else(|| { mcp_invalid_input("Codex conversion: remote MCP spec missing url") })?; table.insert("url".to_string(), toml::Value::String(url.to_string())); if let Some(headers) = obj.get("headers").and_then(Value::as_object) { let mut headers_table = toml::map::Map::new(); for (key, value) in headers { let Some(text) = value.as_str() else { continue; }; let trimmed = text.trim(); if trimmed.is_empty() { continue; } headers_table.insert(key.to_string(), toml::Value::String(trimmed.to_string())); } if !headers_table.is_empty() { table.insert( "http_headers".to_string(), toml::Value::Table(headers_table), ); } } } _ => { return Err(mcp_invalid_input(format!( "Codex conversion: unsupported MCP type '{typ}'" ))); } } for (key, value) in obj { if key == "type" || key == "command" || key == "args" || key == "env" || key == "cwd" || key == "url" || key == "headers" { continue; } if let Some(converted) = json_to_toml_value(value) { table.insert(key.to_string(), converted); } } Ok(toml::Value::Table(table)) } fn read_claude_servers() -> Result, AppCommandError> { let path = claude_config_path(); let root = read_json_file(&path)?; let mut out = BTreeMap::new(); let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { return Ok(out); }; for (id, spec) in servers { match canonicalize_spec(spec, "Claude config") { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid Claude MCP entry id={id}: {err}"); } } } Ok(out) } fn upsert_claude_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { let path = claude_config_path(); let mut root = read_json_file(&path)?; if !root.is_object() { root = json!({}); } let canonical = canonicalize_spec(spec, "Claude write")?; let obj = root.as_object_mut().ok_or_else(|| { mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) })?; if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { obj.insert("mcpServers".to_string(), Value::Object(Map::new())); } let map = obj .get_mut("mcpServers") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) })?; map.insert(id.to_string(), canonical); write_json_file(&path, &root) } fn remove_claude_server(id: &str) -> Result { let path = claude_config_path(); if !path.exists() { return Ok(false); } let mut root = read_json_file(&path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { return Ok(false); }; let removed = servers.remove(id).is_some(); if removed { write_json_file(&path, &root)?; } Ok(removed) } fn read_codex_servers() -> Result, AppCommandError> { let root = read_codex_root_toml()?; let Some(table) = root.as_table() else { return Ok(BTreeMap::new()); }; let mut out = BTreeMap::new(); if let Some(current) = table.get("mcp_servers").and_then(toml::Value::as_table) { for (id, spec) in current { match codex_entry_to_canonical(id, spec) { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid Codex mcp_servers entry id={id}: {err}"); } } } } if let Some(legacy_mcp) = table.get("mcp").and_then(toml::Value::as_table) { if let Some(legacy_servers) = legacy_mcp.get("servers").and_then(toml::Value::as_table) { for (id, spec) in legacy_servers { if out.contains_key(id) { continue; } match codex_entry_to_canonical(id, spec) { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid Codex mcp.servers entry id={id}: {err}"); } } } } } Ok(out) } fn upsert_codex_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { let mut root = read_codex_root_toml()?; let table = root .as_table_mut() .ok_or_else(|| mcp_configuration_invalid("Codex root TOML must be a table"))?; let codex_entry = canonical_to_codex_entry(spec)?; if !table .get("mcp_servers") .map(toml::Value::is_table) .unwrap_or(false) { table.insert( "mcp_servers".to_string(), toml::Value::Table(toml::map::Map::new()), ); } let mcp_servers = table .get_mut("mcp_servers") .and_then(toml::Value::as_table_mut) .ok_or_else(|| mcp_configuration_invalid("Codex mcp_servers must be a TOML table"))?; mcp_servers.insert(id.to_string(), codex_entry); if let Some(legacy_mcp) = table.get_mut("mcp").and_then(toml::Value::as_table_mut) { if let Some(legacy_servers) = legacy_mcp .get_mut("servers") .and_then(toml::Value::as_table_mut) { legacy_servers.remove(id); if legacy_servers.is_empty() { legacy_mcp.remove("servers"); } } if legacy_mcp.is_empty() { table.remove("mcp"); } } write_codex_root_toml(&root) } fn remove_codex_server(id: &str) -> Result { let path = codex_config_toml_path(); if !path.exists() { return Ok(false); } let mut root = read_codex_root_toml()?; let Some(table) = root.as_table_mut() else { return Ok(false); }; let mut removed = false; if let Some(mcp_servers) = table .get_mut("mcp_servers") .and_then(toml::Value::as_table_mut) { removed |= mcp_servers.remove(id).is_some(); if mcp_servers.is_empty() { table.remove("mcp_servers"); } } if let Some(legacy_mcp) = table.get_mut("mcp").and_then(toml::Value::as_table_mut) { if let Some(legacy_servers) = legacy_mcp .get_mut("servers") .and_then(toml::Value::as_table_mut) { removed |= legacy_servers.remove(id).is_some(); if legacy_servers.is_empty() { legacy_mcp.remove("servers"); } } if legacy_mcp.is_empty() { table.remove("mcp"); } } if removed { write_codex_root_toml(&root)?; } Ok(removed) } fn read_opencode_servers() -> Result, AppCommandError> { let path = opencode_config_path(); let root = read_json_file(&path)?; let mut out = BTreeMap::new(); if let Some(servers) = root.get("mcpServers").and_then(Value::as_object) { for (id, spec) in servers { match canonicalize_spec(spec, "OpenCode mcpServers") { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid OpenCode mcpServers entry id={id}: {err}"); } } } } if let Some(servers) = root.get("mcp").and_then(Value::as_object) { for (id, spec) in servers { if out.contains_key(id) { continue; } match canonicalize_opencode_spec(spec, "OpenCode mcp") { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid OpenCode mcp entry id={id}: {err}"); } } } } Ok(out) } fn upsert_opencode_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { let path = opencode_config_path(); let mut root = read_json_file(&path)?; if !root.is_object() { root = json!({}); } let obj = root.as_object_mut().ok_or_else(|| { mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) })?; if obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { let canonical = canonicalize_spec(spec, "OpenCode write mcpServers")?; let map = obj .get_mut("mcpServers") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) })?; map.insert(id.to_string(), canonical); } else { if !obj.get("mcp").map(Value::is_object).unwrap_or(false) { obj.insert("mcp".to_string(), Value::Object(Map::new())); } let converted = canonical_to_opencode_spec(spec)?; let map = obj .get_mut("mcp") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcp in {}", path.display())) })?; map.insert(id.to_string(), converted); } write_json_file(&path, &root) } fn remove_opencode_server(id: &str) -> Result { let path = opencode_config_path(); if !path.exists() { return Ok(false); } let mut root = read_json_file(&path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; let mut removed = false; if let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) { removed |= servers.remove(id).is_some(); } if let Some(servers) = obj.get_mut("mcp").and_then(Value::as_object_mut) { removed |= servers.remove(id).is_some(); } if removed { write_json_file(&path, &root)?; } Ok(removed) } // --------------------------------------------------------------------------- // Gemini CLI (~/.gemini/settings.json → mcpServers) // --------------------------------------------------------------------------- fn read_gemini_servers() -> Result, AppCommandError> { let path = gemini_config_path(); let root = read_json_file(&path)?; let mut out = BTreeMap::new(); let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { return Ok(out); }; for (id, spec) in servers { match canonicalize_spec(spec, "Gemini config") { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid Gemini MCP entry id={id}: {err}"); } } } Ok(out) } fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { let path = gemini_config_path(); let mut root = read_json_file(&path)?; if !root.is_object() { root = json!({}); } let canonical = canonicalize_spec(spec, "Gemini write")?; let obj = root.as_object_mut().ok_or_else(|| { mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) })?; if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { obj.insert("mcpServers".to_string(), Value::Object(Map::new())); } let map = obj .get_mut("mcpServers") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) })?; map.insert(id.to_string(), canonical); write_json_file(&path, &root) } fn remove_gemini_server(id: &str) -> Result { let path = gemini_config_path(); if !path.exists() { return Ok(false); } let mut root = read_json_file(&path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { return Ok(false); }; let removed = servers.remove(id).is_some(); if removed { write_json_file(&path, &root)?; } Ok(removed) } // --------------------------------------------------------------------------- // OpenClaw (~/.openclaw/openclaw.json → mcp.servers) // --------------------------------------------------------------------------- fn read_openclaw_servers() -> Result, AppCommandError> { let path = openclaw_config_path(); let root = read_json_file(&path)?; let mut out = BTreeMap::new(); let Some(mcp) = root.get("mcp").and_then(Value::as_object) else { return Ok(out); }; let Some(servers) = mcp.get("servers").and_then(Value::as_object) else { return Ok(out); }; for (id, spec) in servers { match canonicalize_spec(spec, "OpenClaw config") { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid OpenClaw MCP entry id={id}: {err}"); } } } Ok(out) } fn upsert_openclaw_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { let path = openclaw_config_path(); let mut root = read_json_file(&path)?; if !root.is_object() { root = json!({}); } let canonical = canonicalize_spec(spec, "OpenClaw write")?; let obj = root.as_object_mut().ok_or_else(|| { mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) })?; if !obj.get("mcp").map(Value::is_object).unwrap_or(false) { obj.insert("mcp".to_string(), json!({})); } let mcp = obj .get_mut("mcp") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcp in {}", path.display())) })?; if !mcp.get("servers").map(Value::is_object).unwrap_or(false) { mcp.insert("servers".to_string(), Value::Object(Map::new())); } let servers = mcp .get_mut("servers") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcp.servers in {}", path.display())) })?; servers.insert(id.to_string(), canonical); write_json_file(&path, &root) } fn remove_openclaw_server(id: &str) -> Result { let path = openclaw_config_path(); if !path.exists() { return Ok(false); } let mut root = read_json_file(&path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; let Some(mcp) = obj.get_mut("mcp").and_then(Value::as_object_mut) else { return Ok(false); }; let Some(servers) = mcp.get_mut("servers").and_then(Value::as_object_mut) else { return Ok(false); }; let removed = servers.remove(id).is_some(); if removed { if servers.is_empty() { mcp.remove("servers"); } if mcp.is_empty() { obj.remove("mcp"); } write_json_file(&path, &root)?; } Ok(removed) } // --------------------------------------------------------------------------- // Cline (~/.cline/data/settings/cline_mcp_settings.json → mcpServers) // --------------------------------------------------------------------------- fn read_cline_servers() -> Result, AppCommandError> { let path = cline_config_path(); let root = read_json_file(&path)?; let mut out = BTreeMap::new(); let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else { return Ok(out); }; for (id, spec) in servers { match canonicalize_spec(spec, "Cline config") { Ok(normalized) => { out.insert(id.to_string(), normalized); } Err(err) => { eprintln!("[MCP] skip invalid Cline MCP entry id={id}: {err}"); } } } Ok(out) } fn upsert_cline_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { let path = cline_config_path(); let mut root = read_json_file(&path)?; if !root.is_object() { root = json!({}); } let canonical = canonicalize_spec(spec, "Cline write")?; let obj = root.as_object_mut().ok_or_else(|| { mcp_configuration_invalid(format!("invalid JSON root in {}", path.display())) })?; if !obj.get("mcpServers").map(Value::is_object).unwrap_or(false) { obj.insert("mcpServers".to_string(), Value::Object(Map::new())); } let map = obj .get_mut("mcpServers") .and_then(Value::as_object_mut) .ok_or_else(|| { mcp_configuration_invalid(format!("invalid mcpServers in {}", path.display())) })?; map.insert(id.to_string(), canonical); write_json_file(&path, &root) } fn remove_cline_server(id: &str) -> Result { let path = cline_config_path(); if !path.exists() { return Ok(false); } let mut root = read_json_file(&path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { return Ok(false); }; let removed = servers.remove(id).is_some(); if removed { write_json_file(&path, &root)?; } Ok(removed) } fn scan_local_servers() -> Result, AppCommandError> { let mut merged: BTreeMap)> = BTreeMap::new(); for (id, spec) in read_claude_servers()? { let entry = merged .entry(id) .or_insert_with(|| (spec.clone(), BTreeSet::new())); entry.1.insert(McpAppType::ClaudeCode); } for (id, spec) in read_codex_servers()? { let entry = merged .entry(id) .or_insert_with(|| (spec.clone(), BTreeSet::new())); entry.1.insert(McpAppType::Codex); } for (id, spec) in read_opencode_servers()? { let entry = merged .entry(id) .or_insert_with(|| (spec.clone(), BTreeSet::new())); entry.1.insert(McpAppType::OpenCode); } for (id, spec) in read_gemini_servers()? { let entry = merged .entry(id) .or_insert_with(|| (spec.clone(), BTreeSet::new())); entry.1.insert(McpAppType::Gemini); } for (id, spec) in read_openclaw_servers()? { let entry = merged .entry(id) .or_insert_with(|| (spec.clone(), BTreeSet::new())); entry.1.insert(McpAppType::OpenClaw); } for (id, spec) in read_cline_servers()? { let entry = merged .entry(id) .or_insert_with(|| (spec.clone(), BTreeSet::new())); entry.1.insert(McpAppType::Cline); } Ok(merged .into_iter() .map(|(id, (spec, apps))| LocalMcpServer { id, spec, apps: apps.into_iter().collect(), }) .collect()) } fn find_local_server(server_id: &str) -> Result, AppCommandError> { let servers = scan_local_servers()?; Ok(servers.into_iter().find(|item| item.id == server_id)) } fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), AppCommandError> { match app { McpAppType::ClaudeCode => upsert_claude_server(id, spec), McpAppType::Codex => upsert_codex_server(id, spec), McpAppType::OpenCode => upsert_opencode_server(id, spec), McpAppType::Gemini => upsert_gemini_server(id, spec), McpAppType::OpenClaw => upsert_openclaw_server(id, spec), McpAppType::Cline => upsert_cline_server(id, spec), } } fn remove_server_for_app(app: McpAppType, id: &str) -> Result { match app { McpAppType::ClaudeCode => remove_claude_server(id), McpAppType::Codex => remove_codex_server(id), McpAppType::OpenCode => remove_opencode_server(id), McpAppType::Gemini => remove_gemini_server(id), McpAppType::OpenClaw => remove_openclaw_server(id), McpAppType::Cline => remove_cline_server(id), } } #[derive(Debug, Deserialize)] struct OfficialServerResponse { server: OfficialServer, #[serde(default)] _meta: Option, } #[derive(Debug, Deserialize)] struct OfficialServer { name: String, #[serde(default)] title: Option, #[serde(default)] description: Option, #[serde(default, rename = "websiteUrl")] website_url: Option, #[serde(default)] repository: Option, #[serde(default)] version: Option, #[serde(default)] icons: Option>, #[serde(default)] remotes: Option>, #[serde(default)] packages: Option>, } #[derive(Debug, Deserialize)] struct OfficialRepository { #[serde(default)] url: Option, } #[derive(Debug, Deserialize)] struct OfficialTransport { #[serde(default)] r#type: String, #[serde(default)] url: Option, #[serde(default, deserialize_with = "deserialize_official_key_value_inputs")] headers: Option>, #[serde(default, deserialize_with = "deserialize_official_key_value_inputs")] variables: Option>, } #[derive(Debug, Deserialize)] struct OfficialIcon { #[serde(default)] src: Option, #[serde(default, rename = "mimeType")] _mime_type: Option, #[serde(default)] _sizes: Option>, } #[derive(Debug, Deserialize)] struct OfficialPackage { #[serde(default, rename = "registryType")] registry_type: String, identifier: String, #[serde(default)] version: Option, #[serde(default, rename = "runtimeHint")] runtime_hint: Option, #[serde(default, rename = "runtimeArguments")] runtime_arguments: Vec, #[serde(default, rename = "packageArguments")] package_arguments: Vec, #[serde(default, rename = "environmentVariables")] environment_variables: Vec, transport: OfficialTransport, } #[derive(Debug, Deserialize)] struct OfficialArgument { #[serde(default)] name: Option, #[serde(default)] r#type: Option, #[serde(default)] value: Option, #[serde(default)] default: Option, #[serde(default)] description: Option, #[serde(default)] format: Option, #[serde(default, rename = "isRequired")] is_required: Option, #[serde(default, rename = "isRepeated")] _is_repeated: Option, #[serde(default, rename = "valueHint")] value_hint: Option, } #[derive(Debug, Deserialize)] struct OfficialKeyValueInput { name: String, #[serde(default)] value: Option, #[serde(default)] default: Option, #[serde(default)] description: Option, #[serde(default)] format: Option, #[serde(default, rename = "isRequired")] is_required: Option, #[serde(default, rename = "isSecret")] is_secret: Option, #[serde(default, rename = "valueHint")] value_hint: Option, } fn deserialize_official_key_value_inputs<'de, D>( deserializer: D, ) -> Result>, D::Error> where D: serde::Deserializer<'de>, { let raw = Option::::deserialize(deserializer)?; let Some(value) = raw else { return Ok(None); }; if value.is_null() { return Ok(None); } let mut out = Vec::new(); if let Some(items) = value.as_array() { for item in items { let Ok(parsed) = serde_json::from_value::(item.clone()) else { continue; }; out.push(parsed); } if out.is_empty() { return Ok(None); } return Ok(Some(out)); } if let Some(map) = value.as_object() { for (key, item) in map { let name = key.trim().to_string(); if name.is_empty() { continue; } let mut parsed = OfficialKeyValueInput { name, value: None, default: None, description: None, format: None, is_required: None, is_secret: None, value_hint: None, }; if let Some(text) = item.as_str() { let trimmed = text.trim(); if !trimmed.is_empty() { parsed.value = Some(trimmed.to_string()); } out.push(parsed); continue; } if let Some(obj) = item.as_object() { parsed.value = obj .get("value") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); parsed.default = obj .get("default") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); parsed.description = obj .get("description") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); parsed.format = obj .get("format") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); parsed.is_required = obj.get("isRequired").and_then(Value::as_bool); parsed.is_secret = obj.get("isSecret").and_then(Value::as_bool); parsed.value_hint = obj .get("valueHint") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); } out.push(parsed); } } if out.is_empty() { Ok(None) } else { Ok(Some(out)) } } #[derive(Debug, Deserialize)] struct SmitheryServerListResponse { #[serde(default)] servers: Vec, } #[derive(Debug, Deserialize)] struct SmitheryServerSummary { #[serde(default)] _id: Option, #[serde(rename = "qualifiedName")] qualified_name: String, #[serde(rename = "displayName")] display_name: String, #[serde(default)] description: Option, #[serde(default)] homepage: Option, #[serde(default, rename = "iconUrl")] icon_url: Option, #[serde(default)] namespace: Option, #[serde(default)] owner: Option, #[serde(default)] remote: bool, #[serde(default)] verified: bool, #[serde(default, rename = "useCount")] use_count: Option, #[serde(default)] score: Option, #[serde(default, rename = "isDeployed")] is_deployed: Option, } #[derive(Debug, Deserialize)] struct SmitheryServerDetail { #[serde(rename = "qualifiedName")] qualified_name: String, #[serde(rename = "displayName")] display_name: String, #[serde(default)] description: Option, #[serde(default)] homepage: Option, #[serde(default, rename = "iconUrl")] icon_url: Option, #[serde(default)] namespace: Option, #[serde(default)] owner: Option, #[serde(default, rename = "deploymentUrl")] deployment_url: Option, #[serde(default)] remote: bool, #[serde(default)] verified: bool, #[serde(default, rename = "useCount")] use_count: Option, #[serde(default)] score: Option, #[serde(default, rename = "isDeployed")] is_deployed: Option, #[serde(default)] connections: Vec, } #[derive(Debug, Deserialize)] struct SmitheryConnection { #[serde(default)] r#type: String, #[serde(default, rename = "deploymentUrl")] deployment_url: Option, #[serde(default, rename = "configSchema")] config_schema: Option, } fn first_non_empty_icon_src(icons: Option<&[OfficialIcon]>) -> Option { icons.and_then(|items| { items .iter() .filter_map(|icon| icon.src.as_deref()) .map(str::trim) .find(|value| !value.is_empty()) .map(str::to_string) }) } fn transport_protocol(kind: &str) -> Option { match kind.trim() { "stdio" => Some("stdio".to_string()), "http" | "streamable-http" => Some("http".to_string()), "sse" => Some("sse".to_string()), _ => None, } } fn official_server_protocols(server: &OfficialServer) -> Vec { let mut seen = BTreeSet::new(); if let Some(remotes) = server.remotes.as_ref() { for remote in remotes { if let Some(protocol) = transport_protocol(&remote.r#type) { seen.insert(protocol); } } } if let Some(packages) = server.packages.as_ref() { for package in packages { if let Some(protocol) = transport_protocol(&package.transport.r#type) { seen.insert(protocol); } } } seen.into_iter().collect() } fn official_entry_to_item(entry: &OfficialServerResponse) -> McpMarketplaceItem { let server = &entry.server; let name = server .title .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| server.name.clone()); let description = server .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| "No description".to_string()); let homepage = server .website_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| { server .repository .as_ref() .and_then(|repo| repo.url.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) }); let remote = server .remotes .as_ref() .map(|items| !items.is_empty()) .unwrap_or(false); let verified = entry ._meta .as_ref() .and_then(|meta| { meta.get("io.modelcontextprotocol.registry/official") .and_then(Value::as_object) .and_then(|official| official.get("status")) .and_then(Value::as_str) }) .map(|status| status == "active") .unwrap_or(false); McpMarketplaceItem { provider_id: MARKETPLACE_OFFICIAL.to_string(), server_id: server.name.clone(), name, description, homepage, remote, verified, icon_url: first_non_empty_icon_src(server.icons.as_deref()), latest_version: server .version .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), protocols: official_server_protocols(server), owner: None, namespace: None, downloads: None, score: None, is_deployed: None, } } async fn search_official_registry( query: &str, limit: u32, ) -> Result, AppCommandError> { let client = marketplace_http_client()?; let trimmed = query.trim(); let response = send_request_with_retry("failed to query official MCP registry", || { client .get("https://registry.modelcontextprotocol.io/v0.1/servers") .query(&[ ("limit", limit.to_string()), ("version", "latest".to_string()), ]) .query(&[("search", trimmed.to_string())]) }) .await?; if !response.status().is_success() { return Err(mcp_network(format!( "official MCP registry request failed: HTTP {}", response.status() ))); } let payload = parse_json_value_response(response, "failed to parse official MCP registry response") .await?; let entries = payload .get("servers") .and_then(Value::as_array) .ok_or_else(|| { mcp_configuration_invalid( "failed to parse official MCP registry response: missing servers array", ) })?; let mut out = Vec::new(); for (index, raw_entry) in entries.iter().enumerate() { match serde_json::from_value::(raw_entry.clone()) { Ok(item) => out.push(official_entry_to_item(&item)), Err(err) => { eprintln!( "[MCP] skip invalid official registry server list entry at index={index}: {err}" ); } } } Ok(out) } async fn fetch_official_server_detail( server_name: &str, ) -> Result { let encoded_name = urlencoding::encode(server_name); let url = format!( "https://registry.modelcontextprotocol.io/v0.1/servers/{encoded_name}/versions/latest" ); let client = marketplace_http_client()?; let response = send_request_with_retry("failed to fetch official MCP server detail", || { client.get(url.clone()) }) .await?; if !response.status().is_success() { return Err(mcp_network(format!( "official MCP server detail request failed: HTTP {}", response.status() ))); } parse_json_response::( response, "failed to parse official MCP server detail", ) .await } fn official_remote_option_id(index: usize, protocol: &str) -> String { format!("official:remote:{index}:{protocol}") } fn official_package_option_id(index: usize, protocol: &str) -> String { format!("official:package:{index}:{protocol}") } fn parse_official_option_id(option_id: &str) -> Option<(&str, usize)> { let mut parts = option_id.split(':'); let provider = parts.next()?; let source = parts.next()?; let idx = parts.next()?.parse::().ok()?; if provider != "official" { return None; } Some((source, idx)) } fn select_option_from_list<'a>( options: &'a [McpMarketplaceInstallOption], selection: &InstallSelection, ) -> Result<&'a McpMarketplaceInstallOption, AppCommandError> { if let Some(option_id) = selection.option_id.as_deref() { return options .iter() .find(|item| item.id == option_id) .ok_or_else(|| { mcp_not_found(format!("selected install option not found: {option_id}")) }); } if let Some(protocol) = selection.protocol.as_deref() { let mut by_protocol = options .iter() .filter(|item| normalize_protocol_value(&item.protocol) == protocol); if let Some(first) = by_protocol.next() { let mut best = first; for next in by_protocol { if protocol_priority(&next.protocol) < protocol_priority(&best.protocol) { best = next; } } return Ok(best); } return Err(mcp_not_found(format!( "no install option found for protocol '{protocol}'" ))); } select_default_install_option(options) .ok_or_else(|| mcp_not_found("server does not provide installable options")) } fn key_looks_secret(name: &str) -> bool { let lowered = name.to_ascii_lowercase(); lowered.contains("token") || lowered.contains("secret") || lowered.contains("password") || lowered.contains("api_key") || lowered.ends_with("key") } fn official_text_to_value(kind: &str, value: &str) -> Value { let trimmed = value.trim(); match kind { "boolean" => Value::Bool(trimmed.eq_ignore_ascii_case("true")), "number" => trimmed .parse::() .ok() .and_then(serde_json::Number::from_f64) .map(Value::Number) .unwrap_or_else(|| Value::String(trimmed.to_string())), "integer" => trimmed .parse::() .ok() .map(|item| Value::Number(item.into())) .unwrap_or_else(|| Value::String(trimmed.to_string())), _ => Value::String(trimmed.to_string()), } } fn infer_parameter_kind(format: Option<&str>) -> String { match format.map(str::trim).unwrap_or("string") { "boolean" => "boolean".to_string(), "number" => "number".to_string(), "integer" => "integer".to_string(), "object" | "array" => "json".to_string(), _ => "string".to_string(), } } fn value_as_text(value: &Value) -> Option { match value { Value::String(raw) => { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } Value::Number(raw) => Some(raw.to_string()), Value::Bool(raw) => Some(raw.to_string()), Value::Array(_) | Value::Object(_) => serde_json::to_string(value).ok(), Value::Null => None, } } fn read_parameter_value_as_text(values: &Map, key: &str) -> Option { values.get(key).and_then(value_as_text) } fn official_kv_default(item: &OfficialKeyValueInput) -> Option { item.value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| { item.default .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) }) .filter(|value| !contains_unresolved_placeholder(value)) .map(str::to_string) } fn official_kv_is_required(item: &OfficialKeyValueInput) -> bool { if item.is_required.unwrap_or(false) { return true; } let has_placeholder = item .value .as_deref() .map(contains_unresolved_placeholder) .unwrap_or(false) || item .default .as_deref() .map(contains_unresolved_placeholder) .unwrap_or(false); has_placeholder || official_kv_default(item).is_none() } fn append_query_param(url: &str, key: &str, value: &str) -> String { let encoded_key = urlencoding::encode(key); let encoded_value = urlencoding::encode(value); let separator = if url.contains('?') { '&' } else { '?' }; format!("{url}{separator}{encoded_key}={encoded_value}") } fn apply_transport_variables( base_url: &str, variables: Option<&[OfficialKeyValueInput]>, values: &Map, enforce_required: bool, ) -> Result { let Some(items) = variables else { return Ok(base_url.to_string()); }; let mut url = base_url.to_string(); for item in items { let key_name = item.name.trim(); if key_name.is_empty() { continue; } let field_key = format!("variables.{key_name}"); let value = read_parameter_value_as_text(values, &field_key).or_else(|| official_kv_default(item)); if let Some(text) = value { let encoded = urlencoding::encode(&text); let brace = format!("{{{key_name}}}"); let moustache = format!("{{{{{key_name}}}}}"); if url.contains(&brace) { url = url.replace(&brace, &encoded); } else if url.contains(&moustache) { url = url.replace(&moustache, &encoded); } else { url = append_query_param(&url, key_name, &text); } continue; } if enforce_required && official_kv_is_required(item) { return Err(mcp_invalid_input(format!( "missing required variable '{key_name}'" ))); } } Ok(url) } fn remote_spec_from_transport_with_values( transport: &OfficialTransport, values: &Map, enforce_required: bool, ) -> Result { let kind = transport.r#type.trim(); let canonical_type = match kind { "streamable-http" | "http" => "http", "sse" => "sse", _ => { return Err(mcp_invalid_input(format!( "unsupported transport type '{kind}'" ))) } }; let base_url = transport .url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| mcp_invalid_input("remote transport missing URL"))?; let url = apply_transport_variables( base_url, transport.variables.as_deref(), values, enforce_required, )?; let mut spec = Map::new(); spec.insert( "type".to_string(), Value::String(canonical_type.to_string()), ); spec.insert("url".to_string(), Value::String(url)); let mut headers = Map::new(); if let Some(items) = transport.headers.as_deref() { for item in items { let key_name = item.name.trim(); if key_name.is_empty() { continue; } let field_key = format!("headers.{key_name}"); let value = read_parameter_value_as_text(values, &field_key) .or_else(|| official_kv_default(item)); if let Some(text) = value { headers.insert(key_name.to_string(), Value::String(text)); continue; } if enforce_required && official_kv_is_required(item) { return Err(mcp_invalid_input(format!( "missing required header '{key_name}'" ))); } } } if !headers.is_empty() { spec.insert("headers".to_string(), Value::Object(headers)); } canonicalize_spec(&Value::Object(spec), "official transport") } fn official_remote_parameter_fields( transport: &OfficialTransport, ) -> Vec { let mut fields = Vec::new(); if let Some(headers) = transport.headers.as_deref() { for item in headers { let key = item.name.trim(); if key.is_empty() { continue; } let kind = infer_parameter_kind(item.format.as_deref()); fields.push(McpMarketplaceInstallParameter { key: format!("headers.{key}"), label: key.to_string(), description: item .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), required: official_kv_is_required(item), secret: item.is_secret.unwrap_or(false) || key_looks_secret(key), kind: kind.clone(), default_value: official_kv_default(item) .as_deref() .map(|value| official_text_to_value(&kind, value)), placeholder: item .value_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), enum_values: Vec::new(), location: Some("header".to_string()), }); } } if let Some(variables) = transport.variables.as_deref() { for item in variables { let key = item.name.trim(); if key.is_empty() { continue; } let kind = infer_parameter_kind(item.format.as_deref()); fields.push(McpMarketplaceInstallParameter { key: format!("variables.{key}"), label: key.to_string(), description: item .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), required: official_kv_is_required(item), secret: item.is_secret.unwrap_or(false) || key_looks_secret(key), kind: kind.clone(), default_value: official_kv_default(item) .as_deref() .map(|value| official_text_to_value(&kind, value)), placeholder: item .value_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), enum_values: Vec::new(), location: Some("query".to_string()), }); } } fields } fn build_official_install_options( server: &OfficialServer, ) -> Result, AppCommandError> { let mut options = Vec::new(); if let Some(packages) = server.packages.as_ref() { for (index, package) in packages.iter().enumerate() { let Some(protocol) = transport_protocol(&package.transport.r#type) else { continue; }; if protocol == "stdio" { match resolve_official_stdio_package(package) { Ok(spec) => { let runtime = package .runtime_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("runtime"); options.push(McpMarketplaceInstallOption { id: official_package_option_id(index, &protocol), protocol: protocol.clone(), label: format!("stdio ({runtime})"), description: Some(format!("Run package {}", package.identifier)), spec, parameters: official_stdio_parameter_fields(package), }); } Err(err) => { eprintln!("[MCP] skip invalid official stdio package: {err}"); } } } else if let Ok(spec) = remote_spec_from_transport_with_values(&package.transport, &Map::new(), false) { options.push(McpMarketplaceInstallOption { id: official_package_option_id(index, &protocol), protocol: protocol.clone(), label: format!("{protocol} (package)"), description: Some(format!("Remote package {}", package.identifier)), spec, parameters: official_remote_parameter_fields(&package.transport), }); } } } if let Some(remotes) = server.remotes.as_ref() { for (index, transport) in remotes.iter().enumerate() { let Some(protocol) = transport_protocol(&transport.r#type) else { continue; }; if let Ok(spec) = remote_spec_from_transport_with_values(transport, &Map::new(), false) { options.push(McpMarketplaceInstallOption { id: official_remote_option_id(index, &protocol), protocol: protocol.clone(), label: format!("{protocol} (remote)"), description: transport .url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), spec, parameters: official_remote_parameter_fields(transport), }); } } } if options.is_empty() { return Err(mcp_not_found(format!( "official MCP server '{}' does not expose an installable transport", server.name ))); } Ok(options) } fn resolve_official_install_spec_with_selection( server: &OfficialServer, selection: &InstallSelection, ) -> Result { let options = build_official_install_options(server)?; let selected = select_option_from_list(&options, selection)?; let values = &selection.parameter_values; if let Some((source, index)) = parse_official_option_id(&selected.id) { if source == "package" { let package = server .packages .as_ref() .and_then(|items| items.get(index)) .ok_or_else(|| { mcp_not_found(format!( "selected package option index is out of range: {index}" )) })?; if normalize_protocol_value(&selected.protocol) == "stdio" { return resolve_official_stdio_package_with_values(package, values, true); } return remote_spec_from_transport_with_values(&package.transport, values, true); } if source == "remote" { let remote = server .remotes .as_ref() .and_then(|items| items.get(index)) .ok_or_else(|| { mcp_not_found(format!( "selected remote option index is out of range: {index}" )) })?; return remote_spec_from_transport_with_values(remote, values, true); } } Err(mcp_invalid_input(format!( "unsupported official install option '{}'", selected.id ))) } fn package_identifier_with_version(package: &OfficialPackage, runtime: &str) -> String { let identifier = package.identifier.trim(); if identifier.is_empty() { return String::new(); } let version = package .version .as_deref() .map(str::trim) .filter(|value| !value.is_empty() && *value != "latest"); let Some(version) = version else { return identifier.to_string(); }; if runtime == "uvx" { if package.registry_type.trim() == "pypi" { return format!("{identifier}=={version}"); } return identifier.to_string(); } if runtime == "npx" { if identifier.contains('@') || identifier.starts_with("http") { return identifier.to_string(); } return format!("{identifier}@{version}"); } identifier.to_string() } fn argument_value(arg: &OfficialArgument) -> Option { arg.value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| { arg.default .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) }) .filter(|value| !contains_unresolved_placeholder(value)) .map(str::to_string) } fn argument_is_required(arg: &OfficialArgument) -> bool { arg.is_required.unwrap_or(false) } fn argument_kind(arg: &OfficialArgument) -> String { infer_parameter_kind(arg.format.as_deref()) } fn argument_parameter_key(scope: &str, index: usize) -> String { format!("{scope}.{index}") } fn resolve_argument_value( arg: &OfficialArgument, scope: &str, index: usize, values: &Map, ) -> Option { let key = argument_parameter_key(scope, index); read_parameter_value_as_text(values, &key).or_else(|| argument_value(arg)) } fn append_argument_value( target: &mut Vec, arg: &OfficialArgument, scope: &str, index: usize, values: &Map, enforce_required: bool, ) -> Result<(), AppCommandError> { let kind = arg.r#type.as_deref().map(str::trim).unwrap_or("positional"); let resolved = resolve_argument_value(arg, scope, index, values); if kind == "named" { let Some(name) = arg .name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { return Ok(()); }; if let Some(value) = resolved { target.push(name.to_string()); target.push(value); return Ok(()); } if enforce_required && argument_is_required(arg) { return Err(mcp_invalid_input(format!( "missing required argument '{name}'" ))); } return Ok(()); } if let Some(value) = resolved { target.push(value); return Ok(()); } if enforce_required && argument_is_required(arg) { let name = arg .name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("positional"); return Err(mcp_invalid_input(format!( "missing required argument '{name}'" ))); } Ok(()) } fn official_stdio_parameter_fields( package: &OfficialPackage, ) -> Vec { let mut fields = Vec::new(); for (index, arg) in package.runtime_arguments.iter().enumerate() { let kind = argument_kind(arg); let label = arg .name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("runtime arg {}", index + 1)); fields.push(McpMarketplaceInstallParameter { key: argument_parameter_key("runtime_arguments", index), label, description: arg .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), required: argument_is_required(arg), secret: false, kind: kind.clone(), default_value: argument_value(arg) .as_deref() .map(|value| official_text_to_value(&kind, value)), placeholder: arg .value_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), enum_values: Vec::new(), location: Some("arg".to_string()), }); } for (index, arg) in package.package_arguments.iter().enumerate() { let kind = argument_kind(arg); let label = arg .name .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("package arg {}", index + 1)); fields.push(McpMarketplaceInstallParameter { key: argument_parameter_key("package_arguments", index), label, description: arg .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), required: argument_is_required(arg), secret: false, kind: kind.clone(), default_value: argument_value(arg) .as_deref() .map(|value| official_text_to_value(&kind, value)), placeholder: arg .value_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), enum_values: Vec::new(), location: Some("arg".to_string()), }); } for item in &package.environment_variables { let key = item.name.trim(); if key.is_empty() { continue; } let kind = infer_parameter_kind(item.format.as_deref()); fields.push(McpMarketplaceInstallParameter { key: format!("env.{key}"), label: key.to_string(), description: item .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), required: official_kv_is_required(item), secret: item.is_secret.unwrap_or(false) || key_looks_secret(key), kind: kind.clone(), default_value: official_kv_default(item) .as_deref() .map(|value| official_text_to_value(&kind, value)), placeholder: item .value_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), enum_values: Vec::new(), location: Some("env".to_string()), }); } fields } fn resolve_official_stdio_package(package: &OfficialPackage) -> Result { resolve_official_stdio_package_with_values(package, &Map::new(), false) } fn resolve_official_stdio_package_with_values( package: &OfficialPackage, values: &Map, enforce_required: bool, ) -> Result { let runtime = package .runtime_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| match package.registry_type.trim() { "npm" => Some("npx".to_string()), "pypi" => Some("uvx".to_string()), _ => None, }) .ok_or_else(|| { mcp_configuration_invalid(format!( "official package '{}' missing runtime hint", package.identifier )) })?; let mut args = Vec::new(); if runtime == "npx" { args.push("-y".to_string()); } for (index, arg) in package.runtime_arguments.iter().enumerate() { append_argument_value( &mut args, arg, "runtime_arguments", index, values, enforce_required, )?; } let package_identifier = package_identifier_with_version(package, &runtime); if package_identifier.is_empty() { return Err(mcp_configuration_invalid( "official package identifier is empty", )); } args.push(package_identifier); for (index, arg) in package.package_arguments.iter().enumerate() { append_argument_value( &mut args, arg, "package_arguments", index, values, enforce_required, )?; } let mut env = Map::new(); for item in &package.environment_variables { let key = item.name.trim(); if key.is_empty() { continue; } let field_key = format!("env.{key}"); let value = read_parameter_value_as_text(values, &field_key).or_else(|| official_kv_default(item)); if let Some(value) = value { env.insert(key.to_string(), Value::String(value.to_string())); continue; } if enforce_required && official_kv_is_required(item) { return Err(mcp_invalid_input(format!( "missing required environment variable '{key}'" ))); } } let mut spec = Map::new(); spec.insert("type".to_string(), Value::String("stdio".to_string())); spec.insert("command".to_string(), Value::String(runtime)); if !args.is_empty() { spec.insert( "args".to_string(), Value::Array(args.into_iter().map(Value::String).collect()), ); } if !env.is_empty() { spec.insert("env".to_string(), Value::Object(env)); } Ok(Value::Object(spec)) } async fn search_smithery( query: &str, limit: u32, ) -> Result, AppCommandError> { let client = marketplace_http_client()?; let trimmed = query.trim(); let response = send_request_with_retry("failed to query smithery marketplace", || { client .get("https://api.smithery.ai/servers") .query(&[("limit", limit.to_string()), ("q", trimmed.to_string())]) }) .await?; if !response.status().is_success() { return Err(mcp_network(format!( "smithery marketplace request failed: HTTP {}", response.status() ))); } let payload = parse_json_response::( response, "failed to parse smithery response", ) .await?; Ok(payload .servers .into_iter() .map(|item| McpMarketplaceItem { provider_id: MARKETPLACE_SMITHERY.to_string(), server_id: item.qualified_name, name: item.display_name, description: item .description .unwrap_or_else(|| "No description".to_string()), homepage: item.homepage, remote: item.remote, verified: item.verified, icon_url: item .icon_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), latest_version: None, protocols: if item.remote { vec!["http".to_string()] } else { Vec::new() }, owner: item .owner .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), namespace: item .namespace .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), downloads: item.use_count, score: item.score, is_deployed: item.is_deployed, }) .collect()) } async fn fetch_smithery_server_summary( server_id: &str, ) -> Result { let client = marketplace_http_client()?; let response = send_request_with_retry("failed to fetch smithery server summary", || { client .get("https://api.smithery.ai/servers") .query(&[("limit", "30"), ("q", server_id)]) }) .await?; if !response.status().is_success() { return Err(mcp_network(format!( "smithery server summary request failed: HTTP {}", response.status() ))); } let payload = parse_json_response::( response, "failed to parse smithery server summary", ) .await?; payload .servers .into_iter() .find(|item| item.qualified_name == server_id) .ok_or_else(|| mcp_not_found(format!("smithery server summary not found: {server_id}"))) } async fn fetch_smithery_server_detail( server_id: &str, ) -> Result { let url = format!("https://api.smithery.ai/servers/{server_id}"); let client = marketplace_http_client()?; let response = send_request_with_retry("failed to fetch smithery server detail", || { client.get(url.clone()) }) .await?; if !response.status().is_success() { return Err(mcp_network(format!( "smithery server detail request failed: HTTP {}", response.status() ))); } parse_json_response::(response, "failed to parse smithery server detail") .await } #[derive(Debug, Clone)] struct SmitheryConfigField { key: String, description: Option, required: bool, secret: bool, kind: String, default_value: Option, enum_values: Vec, location: String, } fn smithery_option_id(index: usize, protocol: &str) -> String { format!("smithery:connection:{index}:{protocol}") } fn parse_smithery_option_id(option_id: &str) -> Option { let mut parts = option_id.split(':'); let provider = parts.next()?; let source = parts.next()?; let idx = parts.next()?.parse::().ok()?; if provider != "smithery" || source != "connection" { return None; } Some(idx) } fn smithery_connection_protocol(connection: &SmitheryConnection) -> String { match connection.r#type.trim() { "sse" => "sse".to_string(), "streamable-http" | "http" => "http".to_string(), _ => "http".to_string(), } } fn smithery_connection_url( connection: &SmitheryConnection, fallback: Option<&str>, ) -> Option { connection .deployment_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .or_else(|| { fallback .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) }) } fn smithery_property_kind(prop: &Map) -> String { if let Some(raw) = prop.get("type") { if let Some(typ) = raw.as_str() { return match typ.trim() { "boolean" => "boolean".to_string(), "number" => "number".to_string(), "integer" => "integer".to_string(), "object" | "array" => "json".to_string(), _ => "string".to_string(), }; } if let Some(types) = raw.as_array() { for item in types { let Some(typ) = item.as_str() else { continue; }; if typ == "null" { continue; } return match typ { "boolean" => "boolean".to_string(), "number" => "number".to_string(), "integer" => "integer".to_string(), "object" | "array" => "json".to_string(), _ => "string".to_string(), }; } } } "string".to_string() } fn smithery_field_location(key: &str, prop: &Map, secret: bool) -> String { let explicit = prop .get("x-from") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); if explicit.eq_ignore_ascii_case("header") { return "header".to_string(); } if explicit.eq_ignore_ascii_case("query") { return "query".to_string(); } if secret || key_looks_secret(key) { return "header".to_string(); } "query".to_string() } fn parse_smithery_config_fields(schema: Option<&Value>) -> Vec { let Some(root) = schema.and_then(Value::as_object) else { return Vec::new(); }; let required = root .get("required") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) .unwrap_or_default(); let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new(); }; let mut fields = Vec::new(); for (key, raw_prop) in properties { let Some(prop) = raw_prop.as_object() else { continue; }; let kind = smithery_property_kind(prop); let secret = prop .get("writeOnly") .and_then(Value::as_bool) .unwrap_or(false) || key_looks_secret(key); let location = smithery_field_location(key, prop, secret); let enum_values = prop .get("enum") .and_then(Value::as_array) .map(|values| { values .iter() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) .unwrap_or_default(); fields.push(SmitheryConfigField { key: key.to_string(), description: prop .get("description") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), required: required.contains(key), secret, kind, default_value: prop.get("default").cloned(), enum_values, location, }); } fields } fn smithery_parameter_fields( connection: &SmitheryConnection, ) -> Vec { parse_smithery_config_fields(connection.config_schema.as_ref()) .into_iter() .map(|field| McpMarketplaceInstallParameter { key: field.key.clone(), label: field.key, description: field.description, required: field.required, secret: field.secret, kind: field.kind, default_value: field.default_value, placeholder: None, enum_values: field.enum_values, location: Some(field.location), }) .collect() } fn smithery_header_value_to_text(value: &Value) -> Option { value_as_text(value) } fn smithery_query_value_to_text(value: &Value) -> Option { match value { Value::Array(_) | Value::Object(_) => serde_json::to_string(value).ok(), _ => value_as_text(value), } } fn resolve_smithery_connection_spec_with_values( connection: &SmitheryConnection, fallback_url: Option<&str>, values: &Map, enforce_required: bool, ) -> Result { let protocol = smithery_connection_protocol(connection); let url = smithery_connection_url(connection, fallback_url) .ok_or_else(|| mcp_configuration_invalid("smithery connection missing deployment URL"))?; let config_fields = parse_smithery_config_fields(connection.config_schema.as_ref()); let mut next_url = url; let mut headers = Map::new(); for field in config_fields { let mut value = values.get(&field.key).cloned(); if value.is_none() { value = field.default_value.clone(); } let Some(value) = value else { if enforce_required && field.required { return Err(mcp_invalid_input(format!( "missing required configuration '{}'", field.key ))); } continue; }; if field.location == "header" { if let Some(text) = smithery_header_value_to_text(&value) { headers.insert(field.key, Value::String(text)); } else if enforce_required && field.required { return Err(mcp_invalid_input(format!( "invalid configuration value '{}'", field.key ))); } continue; } if let Some(text) = smithery_query_value_to_text(&value) { next_url = append_query_param(&next_url, &field.key, &text); } else if enforce_required && field.required { return Err(mcp_invalid_input(format!( "invalid configuration value '{}'", field.key ))); } } let mut spec = Map::new(); spec.insert("type".to_string(), Value::String(protocol)); spec.insert("url".to_string(), Value::String(next_url)); if !headers.is_empty() { spec.insert("headers".to_string(), Value::Object(headers)); } canonicalize_spec(&Value::Object(spec), "smithery install") } fn build_smithery_install_options( server: &SmitheryServerDetail, ) -> Result, AppCommandError> { let mut options = Vec::new(); for (index, connection) in server.connections.iter().enumerate() { let protocol = smithery_connection_protocol(connection); if let Ok(spec) = resolve_smithery_connection_spec_with_values( connection, server.deployment_url.as_deref(), &Map::new(), false, ) { options.push(McpMarketplaceInstallOption { id: smithery_option_id(index, &protocol), protocol: protocol.clone(), label: format!("{protocol} (connection {})", index + 1), description: connection .deployment_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), spec, parameters: smithery_parameter_fields(connection), }); } } if options.is_empty() { if let Some(fallback) = server .deployment_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) { let spec = canonicalize_spec( &json!({ "type": "http", "url": fallback, }), "smithery fallback", )?; options.push(McpMarketplaceInstallOption { id: "smithery:fallback:http".to_string(), protocol: "http".to_string(), label: "http".to_string(), description: Some(fallback.to_string()), spec, parameters: Vec::new(), }); } } if options.is_empty() { return Err(mcp_not_found(format!( "smithery server '{}' does not provide installable connection info", server.qualified_name ))); } Ok(options) } fn resolve_smithery_install_spec_with_selection( server: &SmitheryServerDetail, selection: &InstallSelection, ) -> Result { let options = build_smithery_install_options(server)?; let selected = select_option_from_list(&options, selection)?; if let Some(index) = parse_smithery_option_id(&selected.id) { let connection = server.connections.get(index).ok_or_else(|| { mcp_not_found(format!( "selected smithery connection is out of range: {index}" )) })?; return resolve_smithery_connection_spec_with_values( connection, server.deployment_url.as_deref(), &selection.parameter_values, true, ); } canonicalize_spec(&selected.spec, "smithery selected option") }