Files
codeg/src-tauri/src/commands/mcp.rs
2026-03-09 10:05:22 +08:00

3390 lines
107 KiB
Rust

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<Result<reqwest::Client, String>> = 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<String>) -> AppCommandError {
AppCommandError::invalid_input(message)
}
fn mcp_not_found(message: impl Into<String>) -> AppCommandError {
AppCommandError::not_found(message)
}
fn mcp_configuration_invalid(message: impl Into<String>) -> AppCommandError {
AppCommandError::configuration_invalid(message)
}
fn mcp_network(message: impl Into<String>) -> AppCommandError {
AppCommandError::network(message)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum McpAppType {
ClaudeCode,
Codex,
OpenCode,
}
#[derive(Debug, Clone, Serialize)]
pub struct LocalMcpServer {
pub id: String,
pub spec: Value,
pub apps: Vec<McpAppType>,
}
#[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<String>,
pub remote: bool,
pub verified: bool,
pub icon_url: Option<String>,
pub latest_version: Option<String>,
pub protocols: Vec<String>,
pub owner: Option<String>,
pub namespace: Option<String>,
pub downloads: Option<u64>,
pub score: Option<f64>,
pub is_deployed: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpMarketplaceInstallParameter {
pub key: String,
pub label: String,
pub description: Option<String>,
pub required: bool,
pub secret: bool,
pub kind: String,
pub default_value: Option<Value>,
pub placeholder: Option<String>,
pub enum_values: Vec<String>,
pub location: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpMarketplaceInstallOption {
pub id: String,
pub protocol: String,
pub label: String,
pub description: Option<String>,
pub spec: Value,
pub parameters: Vec<McpMarketplaceInstallParameter>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpMarketplaceServerDetail {
pub provider_id: String,
pub server_id: String,
pub name: String,
pub description: String,
pub homepage: Option<String>,
pub remote: bool,
pub verified: bool,
pub icon_url: Option<String>,
pub latest_version: Option<String>,
pub protocols: Vec<String>,
pub owner: Option<String>,
pub namespace: Option<String>,
pub downloads: Option<u64>,
pub score: Option<f64>,
pub is_deployed: Option<bool>,
pub default_option_id: Option<String>,
pub install_options: Vec<McpMarketplaceInstallOption>,
pub spec: Value,
}
#[tauri::command]
pub async fn mcp_scan_local() -> Result<Vec<LocalMcpServer>, AppCommandError> {
scan_local_servers()
}
#[tauri::command]
pub async fn mcp_list_marketplaces() -> Result<Vec<McpMarketplaceProvider>, 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(),
},
])
}
#[tauri::command]
pub async fn mcp_search_marketplace(
provider_id: String,
query: Option<String>,
limit: Option<u32>,
) -> Result<Vec<McpMarketplaceItem>, 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}"
))),
}
}
#[tauri::command]
pub async fn mcp_get_marketplace_server_detail(
provider_id: String,
server_id: String,
) -> Result<McpMarketplaceServerDetail, AppCommandError> {
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}"
))),
}
}
#[tauri::command]
pub async fn mcp_install_from_marketplace(
provider_id: String,
server_id: String,
apps: Vec<McpAppType>,
spec_override: Option<Value>,
option_id: Option<String>,
protocol: Option<String>,
parameter_values: Option<Value>,
) -> Result<LocalMcpServer, AppCommandError> {
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"
))
})
}
#[tauri::command]
pub async fn mcp_upsert_local_server(
server_id: String,
spec: Value,
apps: Vec<McpAppType>,
) -> Result<LocalMcpServer, AppCommandError> {
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::<BTreeSet<_>>();
let all_apps = [
McpAppType::ClaudeCode,
McpAppType::Codex,
McpAppType::OpenCode,
];
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"
))
})
}
#[tauri::command]
pub async fn mcp_set_server_apps(
server_id: String,
apps: Vec<McpAppType>,
) -> Result<Option<LocalMcpServer>, 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::<BTreeSet<_>>();
let current_set = current.apps.iter().copied().collect::<BTreeSet<_>>();
for app in current_set.difference(&target_set) {
remove_server_for_app(*app, &server_id)?;
}
for app in target_set.difference(&current_set) {
upsert_server_for_app(*app, &server_id, &current.spec)?;
}
find_local_server(&server_id)
}
#[tauri::command]
pub async fn mcp_remove_server(
server_id: String,
apps: Option<Vec<McpAppType>>,
) -> Result<bool, AppCommandError> {
let target_apps = match apps {
Some(selected) => normalize_apps(selected),
None => vec![
McpAppType::ClaudeCode,
McpAppType::Codex,
McpAppType::OpenCode,
],
};
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<McpAppType>) -> Vec<McpAppType> {
let mut seen = BTreeSet::new();
for app in apps {
seen.insert(app);
}
seen.into_iter().collect()
}
#[derive(Debug, Clone)]
struct InstallSelection {
option_id: Option<String>,
protocol: Option<String>,
parameter_values: Map<String, Value>,
}
impl InstallSelection {
fn new(
option_id: Option<String>,
protocol: Option<String>,
parameter_values: Option<Value>,
) -> Result<Self, AppCommandError> {
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<String> {
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 read_json_file(path: &Path) -> Result<Value, AppCommandError> {
if !path.exists() {
return Ok(json!({}));
}
let raw = fs::read_to_string(path).map_err(AppCommandError::io)?;
serde_json::from_str::<Value>(&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<toml::Value, AppCommandError> {
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::<toml::Value>().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<Map<String, Value>> {
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<reqwest::Client, AppCommandError> {
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<F>(
context: &str,
mut build: F,
) -> Result<reqwest::Response, AppCommandError>
where
F: FnMut() -> reqwest::RequestBuilder,
{
const MAX_ATTEMPTS: usize = 3;
let mut last_error: Option<String> = 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<T: DeserializeOwned>(
response: reqwest::Response,
context: &str,
) -> Result<T, AppCommandError> {
let raw = response
.text()
.await
.map_err(|e| mcp_network(format!("{context}: failed to read response body: {e}")))?;
serde_json::from_str::<T>(&raw)
.map_err(|e| mcp_network(format!("{context}: invalid JSON response: {e}")))
}
async fn parse_json_value_response(
response: reqwest::Response,
context: &str,
) -> Result<Value, AppCommandError> {
let raw = response
.text()
.await
.map_err(|e| mcp_network(format!("{context}: failed to read response body: {e}")))?;
serde_json::from_str::<Value>(&raw)
.map_err(|e| mcp_network(format!("{context}: invalid JSON response: {e}")))
}
fn canonicalize_spec(spec: &Value, source: &str) -> Result<Value, AppCommandError> {
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::<Vec<_>>();
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<Value, AppCommandError> {
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::<Vec<_>>();
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<Value, AppCommandError> {
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<toml::Value> {
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<Value, AppCommandError> {
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::<Vec<_>>();
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<toml::Value, AppCommandError> {
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::<Vec<_>>();
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<BTreeMap<String, Value>, 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<bool, AppCommandError> {
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<BTreeMap<String, Value>, 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<bool, AppCommandError> {
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<BTreeMap<String, Value>, 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<bool, AppCommandError> {
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)
}
fn scan_local_servers() -> Result<Vec<LocalMcpServer>, AppCommandError> {
let mut merged: BTreeMap<String, (Value, BTreeSet<McpAppType>)> = 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);
}
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<Option<LocalMcpServer>, 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),
}
}
fn remove_server_for_app(app: McpAppType, id: &str) -> Result<bool, AppCommandError> {
match app {
McpAppType::ClaudeCode => remove_claude_server(id),
McpAppType::Codex => remove_codex_server(id),
McpAppType::OpenCode => remove_opencode_server(id),
}
}
#[derive(Debug, Deserialize)]
struct OfficialServerResponse {
server: OfficialServer,
#[serde(default)]
_meta: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct OfficialServer {
name: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "websiteUrl")]
website_url: Option<String>,
#[serde(default)]
repository: Option<OfficialRepository>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
icons: Option<Vec<OfficialIcon>>,
#[serde(default)]
remotes: Option<Vec<OfficialTransport>>,
#[serde(default)]
packages: Option<Vec<OfficialPackage>>,
}
#[derive(Debug, Deserialize)]
struct OfficialRepository {
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OfficialTransport {
#[serde(default)]
r#type: String,
#[serde(default)]
url: Option<String>,
#[serde(default, deserialize_with = "deserialize_official_key_value_inputs")]
headers: Option<Vec<OfficialKeyValueInput>>,
#[serde(default, deserialize_with = "deserialize_official_key_value_inputs")]
variables: Option<Vec<OfficialKeyValueInput>>,
}
#[derive(Debug, Deserialize)]
struct OfficialIcon {
#[serde(default)]
src: Option<String>,
#[serde(default, rename = "mimeType")]
_mime_type: Option<String>,
#[serde(default)]
_sizes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct OfficialPackage {
#[serde(default, rename = "registryType")]
registry_type: String,
identifier: String,
#[serde(default)]
version: Option<String>,
#[serde(default, rename = "runtimeHint")]
runtime_hint: Option<String>,
#[serde(default, rename = "runtimeArguments")]
runtime_arguments: Vec<OfficialArgument>,
#[serde(default, rename = "packageArguments")]
package_arguments: Vec<OfficialArgument>,
#[serde(default, rename = "environmentVariables")]
environment_variables: Vec<OfficialKeyValueInput>,
transport: OfficialTransport,
}
#[derive(Debug, Deserialize)]
struct OfficialArgument {
#[serde(default)]
name: Option<String>,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
value: Option<String>,
#[serde(default)]
default: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
format: Option<String>,
#[serde(default, rename = "isRequired")]
is_required: Option<bool>,
#[serde(default, rename = "isRepeated")]
_is_repeated: Option<bool>,
#[serde(default, rename = "valueHint")]
value_hint: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OfficialKeyValueInput {
name: String,
#[serde(default)]
value: Option<String>,
#[serde(default)]
default: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
format: Option<String>,
#[serde(default, rename = "isRequired")]
is_required: Option<bool>,
#[serde(default, rename = "isSecret")]
is_secret: Option<bool>,
#[serde(default, rename = "valueHint")]
value_hint: Option<String>,
}
fn deserialize_official_key_value_inputs<'de, D>(
deserializer: D,
) -> Result<Option<Vec<OfficialKeyValueInput>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = Option::<Value>::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::<OfficialKeyValueInput>(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<SmitheryServerSummary>,
}
#[derive(Debug, Deserialize)]
struct SmitheryServerSummary {
#[serde(default)]
_id: Option<String>,
#[serde(rename = "qualifiedName")]
qualified_name: String,
#[serde(rename = "displayName")]
display_name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default, rename = "iconUrl")]
icon_url: Option<String>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
owner: Option<String>,
#[serde(default)]
remote: bool,
#[serde(default)]
verified: bool,
#[serde(default, rename = "useCount")]
use_count: Option<u64>,
#[serde(default)]
score: Option<f64>,
#[serde(default, rename = "isDeployed")]
is_deployed: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct SmitheryServerDetail {
#[serde(rename = "qualifiedName")]
qualified_name: String,
#[serde(rename = "displayName")]
display_name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
homepage: Option<String>,
#[serde(default, rename = "iconUrl")]
icon_url: Option<String>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
owner: Option<String>,
#[serde(default, rename = "deploymentUrl")]
deployment_url: Option<String>,
#[serde(default)]
remote: bool,
#[serde(default)]
verified: bool,
#[serde(default, rename = "useCount")]
use_count: Option<u64>,
#[serde(default)]
score: Option<f64>,
#[serde(default, rename = "isDeployed")]
is_deployed: Option<bool>,
#[serde(default)]
connections: Vec<SmitheryConnection>,
}
#[derive(Debug, Deserialize)]
struct SmitheryConnection {
#[serde(default)]
r#type: String,
#[serde(default, rename = "deploymentUrl")]
deployment_url: Option<String>,
#[serde(default, rename = "configSchema")]
config_schema: Option<Value>,
}
fn first_non_empty_icon_src(icons: Option<&[OfficialIcon]>) -> Option<String> {
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<String> {
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<String> {
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<Vec<McpMarketplaceItem>, 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::<OfficialServerResponse>(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<OfficialServerResponse, AppCommandError> {
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::<OfficialServerResponse>(
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::<usize>().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::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or_else(|| Value::String(trimmed.to_string())),
"integer" => trimmed
.parse::<i64>()
.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<String> {
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<String, Value>, key: &str) -> Option<String> {
values.get(key).and_then(value_as_text)
}
fn official_kv_default(item: &OfficialKeyValueInput) -> Option<String> {
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<String, Value>,
enforce_required: bool,
) -> Result<String, AppCommandError> {
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<String, Value>,
enforce_required: bool,
) -> Result<Value, AppCommandError> {
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<McpMarketplaceInstallParameter> {
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<Vec<McpMarketplaceInstallOption>, 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<Value, AppCommandError> {
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<String> {
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<String, Value>,
) -> Option<String> {
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<String>,
arg: &OfficialArgument,
scope: &str,
index: usize,
values: &Map<String, Value>,
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<McpMarketplaceInstallParameter> {
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<Value, AppCommandError> {
resolve_official_stdio_package_with_values(package, &Map::new(), false)
}
fn resolve_official_stdio_package_with_values(
package: &OfficialPackage,
values: &Map<String, Value>,
enforce_required: bool,
) -> Result<Value, AppCommandError> {
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<Vec<McpMarketplaceItem>, 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::<SmitheryServerListResponse>(
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<SmitheryServerSummary, AppCommandError> {
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::<SmitheryServerListResponse>(
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<SmitheryServerDetail, AppCommandError> {
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::<SmitheryServerDetail>(response, "failed to parse smithery server detail")
.await
}
#[derive(Debug, Clone)]
struct SmitheryConfigField {
key: String,
description: Option<String>,
required: bool,
secret: bool,
kind: String,
default_value: Option<Value>,
enum_values: Vec<String>,
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<usize> {
let mut parts = option_id.split(':');
let provider = parts.next()?;
let source = parts.next()?;
let idx = parts.next()?.parse::<usize>().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<String> {
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, Value>) -> 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<String, Value>, 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<SmitheryConfigField> {
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::<BTreeSet<_>>()
})
.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::<Vec<_>>()
})
.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<McpMarketplaceInstallParameter> {
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<String> {
value_as_text(value)
}
fn smithery_query_value_to_text(value: &Value) -> Option<String> {
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<String, Value>,
enforce_required: bool,
) -> Result<Value, AppCommandError> {
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<Vec<McpMarketplaceInstallOption>, 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<Value, AppCommandError> {
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")
}