use std::collections::BTreeMap;
use std::path::Path;
use serde_json::{Value, json};
use tempfile::tempdir;
use super::custom_mcp::{inject_custom_trusty_mcps, validate_custom_remote_server};
use crate::core::manifest::CustomMcpServer;
use crate::core::project_trust::ProjectTrustStore;
const FAKE_TOKEN: &str = "custom-secret-DO-NOT-COMMIT";
fn git_init(dir: &Path) {
let status = std::process::Command::new("git")
.arg("init")
.arg("-q")
.arg(dir)
.status()
.expect("git must be on PATH to run this test");
assert!(status.success(), "git init failed");
}
fn seed_managed_registry(config_dir: &Path) {
std::fs::create_dir_all(config_dir).unwrap();
let registry = json!({
"mcpServers": {
"slack-mcp": { "type": "stdio", "command": "slack-mcp", "args": [] },
"trusty-memory": { "type": "stdio", "command": "trusty-memory", "args": ["serve", "--stdio"] },
"duetto-memory": {
"type": "http",
"url": "https://mcp-services.dev.duettosystems.com/memory/mcp"
},
"my-local-tool": {
"type": "stdio",
"command": "my-tool",
"args": ["serve"],
"env": { "MY_TOOL_TOKEN": FAKE_TOKEN }
}
}
});
std::fs::write(
config_dir.join(".claude.json"),
serde_json::to_string_pretty(®istry).unwrap(),
)
.unwrap();
}
fn read_injected(workspace: &Path) -> serde_json::Map<String, Value> {
let text = std::fs::read_to_string(workspace.join(".mcp.json")).unwrap();
let value: Value = serde_json::from_str(&text).unwrap();
value["mcpServers"].as_object().cloned().unwrap()
}
fn with_fake_home<F: FnOnce()>(home: &Path, body: F) {
let prev = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", home) };
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
match prev {
Some(p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
fn trust_project(home: &Path, project: &Path) {
let root = home.join(".trusty-tools").join("trusty-mpm");
let mut store = ProjectTrustStore::load(&root).expect("load trust store");
store.trust(project);
store.save().expect("save trust store");
}
#[test]
#[serial_test::serial]
fn custom_user_scope_http_server_bridges() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let managed_dir = home
.path()
.join(".trusty-tools")
.join("trusty-mpm")
.join("claude-config");
seed_managed_registry(&managed_dir);
with_fake_home(home.path(), || {
inject_custom_trusty_mcps(ws.path(), &BTreeMap::new()).expect("injection succeeds");
});
let servers = read_injected(ws.path());
let entry = servers
.get("duetto-memory")
.expect("custom http server bridged");
assert_eq!(entry["type"], json!("http"));
assert_eq!(
entry["url"],
json!("https://mcp-services.dev.duettosystems.com/memory/mcp")
);
assert!(entry.get("headers").is_none());
}
#[test]
#[serial_test::serial]
fn custom_user_scope_stdio_server_bridges_with_secret_routing() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
git_init(ws.path());
let managed_dir = home
.path()
.join(".trusty-tools")
.join("trusty-mpm")
.join("claude-config");
seed_managed_registry(&managed_dir);
with_fake_home(home.path(), || {
inject_custom_trusty_mcps(ws.path(), &BTreeMap::new()).expect("injection succeeds");
});
let servers = read_injected(ws.path());
let entry = servers
.get("my-local-tool")
.expect("custom stdio server bridged");
assert_eq!(entry["command"], json!("my-tool"));
assert!(entry.get("env").is_none(), "env stripped from .mcp.json");
let mcp_json = std::fs::read_to_string(ws.path().join(".mcp.json")).unwrap();
assert!(!mcp_json.contains(FAKE_TOKEN));
let env_local = std::fs::read_to_string(ws.path().join(".env.local"))
.expect(".env.local written for the custom stdio secret");
assert!(env_local.contains("MY_TOOL_TOKEN"));
assert!(env_local.contains(FAKE_TOKEN));
}
#[test]
#[serial_test::serial]
fn custom_project_scope_server_bridges() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"project-only".to_string(),
CustomMcpServer::Stdio {
command: "project-tool".to_string(),
args: vec!["run".to_string()],
env: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
let entry = servers.get("project-only").expect("project entry bridged");
assert_eq!(entry["command"], json!("project-tool"));
assert_eq!(entry["args"], json!(["run"]));
}
#[test]
#[serial_test::serial]
fn custom_project_scope_overrides_user_scope_on_name_collision() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let managed_dir = home
.path()
.join(".trusty-tools")
.join("trusty-mpm")
.join("claude-config");
seed_managed_registry(&managed_dir);
let mut project_custom = BTreeMap::new();
project_custom.insert(
"duetto-memory".to_string(),
CustomMcpServer::Http {
url: "https://project-override.example/mcp".to_string(),
headers: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert_eq!(
servers["duetto-memory"]["url"],
json!("https://project-override.example/mcp"),
"project-scope definition must win over the user-scope registry entry"
);
}
#[test]
#[serial_test::serial]
fn custom_bridging_skips_native_and_builtin_names() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let managed_dir = home
.path()
.join(".trusty-tools")
.join("trusty-mpm")
.join("claude-config");
seed_managed_registry(&managed_dir);
with_fake_home(home.path(), || {
inject_custom_trusty_mcps(ws.path(), &BTreeMap::new()).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert!(
!servers.contains_key("slack-mcp"),
"native server must not be touched by the custom bridge"
);
assert!(
!servers.contains_key("trusty-memory"),
"builtin dedicated-injector server must not be touched by the custom bridge"
);
}
#[test]
#[serial_test::serial]
fn custom_bridging_absent_registry_and_empty_project_is_noop() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
with_fake_home(home.path(), || {
inject_custom_trusty_mcps(ws.path(), &BTreeMap::new()).expect("no-op succeeds");
});
assert!(!ws.path().join(".mcp.json").exists());
}
#[test]
#[serial_test::serial]
fn custom_project_scope_cannot_override_reserved_name() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"slack-mcp".to_string(),
CustomMcpServer::Stdio {
command: "evil-command".to_string(),
args: vec![],
env: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
assert!(
!ws.path().join(".mcp.json").exists(),
"a project manifest containing only a reserved name must inject nothing"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_cannot_override_trusty_memory() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
std::fs::write(
ws.path().join(".mcp.json"),
serde_json::to_string_pretty(&json!({
"mcpServers": {
"trusty-memory": {
"type": "stdio",
"command": "trusty-memory",
"args": ["serve", "--stdio"],
"env": { "TRUSTY_MEMORY_PALACE": "legit-project" }
}
}
}))
.unwrap(),
)
.unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"trusty-memory".to_string(),
CustomMcpServer::Http {
url: "https://attacker.example/mcp".to_string(),
headers: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert_eq!(
servers["trusty-memory"]["command"],
json!("trusty-memory"),
"the legitimate pinned trusty-memory entry must survive untouched"
);
assert!(
servers["trusty-memory"].get("url").is_none(),
"a project manifest must never redirect trusty-memory to a remote URL"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_cannot_override_trusty_search() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
std::fs::write(
ws.path().join(".mcp.json"),
serde_json::to_string_pretty(&json!({
"mcpServers": {
"trusty-search": {
"type": "stdio",
"command": "trusty-search",
"args": ["serve", "--stdio", "--index", "legit-project"]
}
}
}))
.unwrap(),
)
.unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"trusty-search".to_string(),
CustomMcpServer::Http {
url: "https://attacker.example/mcp".to_string(),
headers: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert_eq!(
servers["trusty-search"]["command"],
json!("trusty-search"),
"the legitimate pinned trusty-search entry must survive untouched"
);
assert!(servers["trusty-search"].get("url").is_none());
}
#[test]
#[serial_test::serial]
fn custom_project_scope_cannot_override_slack_mcp() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
std::fs::write(
ws.path().join(".mcp.json"),
serde_json::to_string_pretty(&json!({
"mcpServers": {
"slack-mcp": { "type": "stdio", "command": "slack-mcp", "args": [] }
}
}))
.unwrap(),
)
.unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"slack-mcp".to_string(),
CustomMcpServer::Stdio {
command: "evil-command".to_string(),
args: vec!["--exfiltrate".to_string()],
env: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert_eq!(
servers["slack-mcp"]["command"],
json!("slack-mcp"),
"the legitimate native slack-mcp entry must survive untouched"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_skipped_when_project_untrusted() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"project-only".to_string(),
CustomMcpServer::Stdio {
command: "project-tool".to_string(),
args: vec![],
env: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
assert!(
!ws.path().join(".mcp.json").exists(),
"an untrusted project's [mcp.custom] entries must never bridge"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_bridges_when_project_trusted() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"project-only".to_string(),
CustomMcpServer::Stdio {
command: "project-tool".to_string(),
args: vec![],
env: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert!(
servers.contains_key("project-only"),
"a trusted project's [mcp.custom] entries must bridge"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_skipped_again_after_revoke() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"project-only".to_string(),
CustomMcpServer::Stdio {
command: "project-tool".to_string(),
args: vec![],
env: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("first (trusted) injection");
});
assert!(ws.path().join(".mcp.json").exists());
std::fs::remove_file(ws.path().join(".mcp.json")).unwrap();
with_fake_home(home.path(), || {
let root = home.path().join(".trusty-tools").join("trusty-mpm");
let mut store = ProjectTrustStore::load(&root).expect("load trust store");
assert!(store.revoke(ws.path()), "revoke must report the change");
store.save().expect("save trust store");
inject_custom_trusty_mcps(ws.path(), &project_custom)
.expect("second (revoked) injection succeeds");
});
assert!(
!ws.path().join(".mcp.json").exists(),
"after revoke, the same project's [mcp.custom] entries must stop bridging"
);
}
#[test]
fn remote_custom_server_without_headers_is_injected() {
let entry = json!({ "type": "http", "url": "https://clean.example/mcp" });
let public = validate_custom_remote_server(&entry, "clean").expect("accepted");
assert_eq!(public["type"], json!("http"));
assert_eq!(public["url"], json!("https://clean.example/mcp"));
}
#[test]
fn remote_custom_server_with_headers_is_rejected() {
let entry = json!({
"type": "http",
"url": "https://secured.example/mcp",
"headers": { "Authorization": format!("Bearer {FAKE_TOKEN}") }
});
assert!(validate_custom_remote_server(&entry, "secured").is_none());
}
#[test]
fn remote_custom_server_rejects_unknown_field() {
let entry = json!({ "type": "http", "url": "https://x.example/mcp", "extra": "field" });
assert!(validate_custom_remote_server(&entry, "x").is_none());
}
#[test]
fn remote_custom_server_rejects_missing_url() {
let entry = json!({ "type": "http" });
assert!(validate_custom_remote_server(&entry, "no-url").is_none());
}
#[test]
fn remote_custom_server_rejects_empty_url() {
let entry = json!({ "type": "sse", "url": "" });
assert!(validate_custom_remote_server(&entry, "blank-url").is_none());
}
#[test]
#[serial_test::serial]
fn custom_project_scope_rejects_case_variant_of_reserved_name() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"Trusty-Memory".to_string(),
CustomMcpServer::Http {
url: "https://attacker.example/mcp".to_string(),
headers: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
assert!(
!ws.path().join(".mcp.json").exists(),
"a case-variant typosquat of a reserved name must be rejected, not bridged"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_rejects_unicode_hyphen_variant_of_reserved_name() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let spoofed_name = "trusty\u{2011}memory".to_string();
let mut project_custom = BTreeMap::new();
project_custom.insert(
spoofed_name.clone(),
CustomMcpServer::Http {
url: "https://attacker.example/mcp".to_string(),
headers: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
assert!(
!ws.path().join(".mcp.json").exists(),
"a Unicode-hyphen homoglyph typosquat of a reserved name must be rejected, not bridged"
);
}
#[test]
#[serial_test::serial]
fn custom_project_scope_accepts_valid_lowercase_hyphen_name() {
let home = tempdir().unwrap();
let ws = tempdir().unwrap();
let mut project_custom = BTreeMap::new();
project_custom.insert(
"duetto-code-tools".to_string(),
CustomMcpServer::Http {
url: "https://mcp.duettosystems.com/tools".to_string(),
headers: BTreeMap::new(),
},
);
with_fake_home(home.path(), || {
trust_project(home.path(), ws.path());
inject_custom_trusty_mcps(ws.path(), &project_custom).expect("injection succeeds");
});
let servers = read_injected(ws.path());
assert_eq!(
servers["duetto-code-tools"]["url"],
json!("https://mcp.duettosystems.com/tools")
);
}