3390 lines
107 KiB
Rust
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(¤t_set) {
|
|
upsert_server_for_app(*app, &server_id, ¤t.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")
|
|
}
|