use std::collections::BTreeMap;
use anyhow::{Context, Result};
use crate::shared::config::McpServerConfig;
use crate::shared::i18n::Locale;
use crate::shared::mcp::valid_env_name;
#[derive(Debug, PartialEq)]
pub struct ImportedServer {
pub cfg: McpServerConfig,
pub secrets: Vec<(String, String)>,
}
#[derive(Debug, Default, PartialEq)]
pub struct ImportPlan {
pub servers: Vec<ImportedServer>,
pub skipped_existing: Vec<String>,
pub skipped_unsupported: Vec<String>,
}
impl ImportPlan {
pub fn secret_count(&self) -> usize {
self.servers.iter().map(|s| s.secrets.len()).sum()
}
}
pub fn plan_import(json: &str, existing_ids: &[String], loc: &Locale) -> Result<ImportPlan> {
let root: serde_json::Value = serde_json::from_str(json.trim_start_matches('\u{feff}'))
.with_context(|| loc.t("mcp.import.err.bad_json").to_string())?;
let map = root
.get("mcpServers")
.or_else(|| root.get("servers"))
.and_then(|v| v.as_object())
.with_context(|| loc.t("mcp.import.err.no_servers").to_string())?;
let mut plan = ImportPlan::default();
let mut taken: Vec<String> = existing_ids.to_vec();
for (name, entry) in map {
let command = entry
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if command.is_empty() {
plan.skipped_unsupported.push(name.clone());
continue;
}
let base = sanitize_id(name);
if existing_ids.contains(&base) {
plan.skipped_existing.push(name.clone());
continue;
}
let id = unique_id(&base, &taken);
let args = entry
.get("args")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let (env, secrets) = plan_env(entry);
taken.push(id.clone());
plan.servers.push(ImportedServer {
cfg: McpServerConfig {
id,
command,
args,
env,
enabled: false,
..Default::default()
},
secrets,
});
}
Ok(plan)
}
fn plan_env(entry: &serde_json::Value) -> (BTreeMap<String, String>, Vec<(String, String)>) {
let mut env = BTreeMap::new();
let mut secrets = Vec::new();
if let Some(vars) = entry.get("env").and_then(|v| v.as_object()) {
for (var, value) in vars {
if !valid_env_name(var) {
continue;
}
env.insert(var.clone(), String::new());
let value = value.as_str().unwrap_or("").to_string();
if !value.is_empty() {
secrets.push((var.clone(), value));
}
}
}
(env, secrets)
}
pub fn summary(plan: &ImportPlan, loc: &Locale) -> String {
loc.tf(
"mcp.import.done",
&[
("n", &plan.servers.len().to_string()),
(
"skipped",
&(plan.skipped_existing.len() + plan.skipped_unsupported.len()).to_string(),
),
("secrets", &plan.secret_count().to_string()),
],
)
}
fn sanitize_id(name: &str) -> String {
let mut out = String::new();
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-').to_string();
let out: String = out.chars().take(32).collect();
let out = out.trim_matches('-').to_string();
if out.is_empty() {
"server".to_string()
} else {
out
}
}
fn unique_id(base: &str, taken: &[String]) -> String {
if !taken.iter().any(|t| t == base) {
return base.to_string();
}
(2..)
.map(|n| {
let suffix = format!("-{n}");
let head: String = base.chars().take(32 - suffix.len()).collect();
format!("{head}{suffix}")
})
.find(|c| !taken.contains(c))
.unwrap_or_else(|| base.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
const SAMPLE: &str = r#"{
"mcpServers": {
"My Server": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "D:/work"],
"env": { "GITHUB_TOKEN": "ghp_live_secret", "EMPTY": "", "bad-name": "x" },
"unknownFieldFromSomeOtherClient": true
},
"existing": { "command": "whatever" },
"remote": { "type": "sse", "url": "https://example/mcp" }
}
}"#;
#[test]
fn plans_servers_secrets_and_skips() {
let plan = plan_import(SAMPLE, &["existing".to_string()], ru()).unwrap();
assert_eq!(plan.servers.len(), 1);
let s = &plan.servers[0];
assert_eq!(
s.cfg.id, "my-server",
"the free-form key is sanitized to a slug"
);
assert_eq!(s.cfg.command, "npx");
assert_eq!(s.cfg.args.len(), 3);
assert!(!s.cfg.enabled, "imported servers never spawn on arrival");
assert_eq!(
s.secrets,
vec![("GITHUB_TOKEN".to_string(), "ghp_live_secret".to_string())]
);
assert_eq!(
s.cfg.env.keys().collect::<Vec<_>>(),
vec!["EMPTY", "GITHUB_TOKEN"]
);
assert!(s.cfg.env.values().all(String::is_empty));
assert_eq!(plan.skipped_existing, vec!["existing"]);
assert_eq!(plan.skipped_unsupported, vec!["remote"]);
assert_eq!(plan.secret_count(), 1);
}
#[test]
fn reimport_adds_nothing() {
let first = plan_import(SAMPLE, &[], ru()).unwrap();
let ids: Vec<String> = first.servers.iter().map(|s| s.cfg.id.clone()).collect();
let again = plan_import(SAMPLE, &ids, ru()).unwrap();
assert!(again.servers.is_empty(), "a re-import is a no-op");
assert_eq!(again.skipped_existing.len(), 2);
}
#[test]
fn vs_code_servers_key_is_accepted() {
let plan = plan_import(r#"{"servers":{"fs":{"command":"x"}}}"#, &[], ru()).unwrap();
assert_eq!(plan.servers.len(), 1);
}
#[test]
fn bad_input_is_an_error_not_an_empty_plan() {
assert!(plan_import("not json", &[], ru()).is_err());
assert!(plan_import(r#"{"other": 1}"#, &[], ru()).is_err());
}
#[test]
fn id_sanitization_and_uniqueness() {
assert_eq!(sanitize_id("My_Server"), "my-server");
assert_eq!(sanitize_id(" ... "), "server");
assert_eq!(sanitize_id(&"a".repeat(40)).len(), 32);
assert_eq!(unique_id("fs", &["fs".into()]), "fs-2");
assert_eq!(unique_id("fs", &["fs".into(), "fs-2".into()]), "fs-3");
let plan = plan_import(
r#"{"mcpServers":{"My Server":{"command":"a"},"my_server":{"command":"b"}}}"#,
&[],
ru(),
)
.unwrap();
assert_eq!(plan.servers.len(), 2);
assert_ne!(plan.servers[0].cfg.id, plan.servers[1].cfg.id);
}
}