| 1 | //! Consent-gated external MCP imports. |
| 2 | //! |
| 3 | //! Discovery can scan `~/.claude.json`, project `.mcp.json`, and marketplace |
| 4 | //! manifests, but **nothing is connected until the user approves**. Provenance |
| 5 | //! (source path + content hash) is shown before import. `enabled=false` and |
| 6 | //! `disabled=true` on a source entry are hard blocks — those candidates never |
| 7 | //! become managed connectors even after a blanket approval. |
| 8 | //! |
| 9 | //! Design (Kimi session_a75a393a-a984-4f35-98d0-b78cfbdcf23f): keep discovery |
| 10 | //! pure and independent of the TUI; merge approved servers through the same |
| 11 | //! config write path as `/mcp add`. |
| 12 | |
| 13 | use std::collections::HashMap; |
| 14 | use std::fs; |
| 15 | use std::path::{Path, PathBuf}; |
| 16 | |
| 17 | use serde::{Deserialize, Serialize}; |
| 18 | use serde_json::Value; |
| 19 | use sha2::{Digest, Sha256}; |
| 20 | |
| 21 | use super::{McpConfig, McpServerConfig}; |
| 22 | |
| 23 | /// Where an import candidate came from. |
| 24 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 25 | #[serde(rename_all = "snake_case")] |
| 26 | pub enum ExternalMcpSourceKind { |
| 27 | ClaudeJson, |
| 28 | ProjectMcpJson, |
| 29 | Marketplace, |
| 30 | } |
| 31 | |
| 32 | impl ExternalMcpSourceKind { |
| 33 | #[must_use] |
| 34 | pub fn as_str(&self) -> &'static str { |
| 35 | match self { |
| 36 | Self::ClaudeJson => "claude.json", |
| 37 | Self::ProjectMcpJson => ".mcp.json", |
| 38 | Self::Marketplace => "marketplace", |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// One discovered server before consent. |
| 44 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 45 | pub struct ImportCandidate { |
| 46 | pub name: String, |
| 47 | pub source_kind: ExternalMcpSourceKind, |
| 48 | pub source_path: PathBuf, |
| 49 | /// Hex sha256 of the raw source file (or marketplace entry blob). |
| 50 | pub content_hash: String, |
| 51 | pub summary: String, |
| 52 | /// When true the entry is present but must never connect. |
| 53 | pub hard_blocked: bool, |
| 54 | pub block_reason: Option<String>, |
| 55 | pub server: McpServerConfig, |
| 56 | } |
| 57 | |
| 58 | /// User decision for one candidate. |
| 59 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 60 | #[serde(rename_all = "snake_case")] |
| 61 | pub enum ImportDecision { |
| 62 | Approve, |
| 63 | Decline, |
| 64 | Skip, |
| 65 | } |
| 66 | |
| 67 | /// Durable consent / decline record keyed by source path + hash. |
| 68 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 69 | pub struct ImportConsentStore { |
| 70 | #[serde(default)] |
| 71 | pub entries: HashMap<String, ConsentEntry>, |
| 72 | } |
| 73 | |
| 74 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 75 | pub struct ConsentEntry { |
| 76 | pub source_path: String, |
| 77 | pub content_hash: String, |
| 78 | pub decision: ImportDecision, |
| 79 | pub decided_at_unix: u64, |
| 80 | pub servers: Vec<String>, |
| 81 | } |
| 82 | |
| 83 | fn consent_key(path: &Path, hash: &str) -> String { |
| 84 | format!("{}::{hash}", path.display()) |
| 85 | } |
| 86 | |
| 87 | /// Discover candidates from well-known external locations. Never connects. |
| 88 | pub fn discover_external_sources( |
| 89 | home: &Path, |
| 90 | workspace: &Path, |
| 91 | marketplace_paths: &[PathBuf], |
| 92 | ) -> Vec<ImportCandidate> { |
| 93 | let mut out = Vec::new(); |
| 94 | let claude = home.join(".claude.json"); |
| 95 | if claude.is_file() { |
| 96 | out.extend(discover_from_json_file( |
| 97 | &claude, |
| 98 | ExternalMcpSourceKind::ClaudeJson, |
| 99 | )); |
| 100 | } |
| 101 | let project_mcp = workspace.join(".mcp.json"); |
| 102 | if project_mcp.is_file() { |
| 103 | out.extend(discover_from_json_file( |
| 104 | &project_mcp, |
| 105 | ExternalMcpSourceKind::ProjectMcpJson, |
| 106 | )); |
| 107 | } |
| 108 | for path in marketplace_paths { |
| 109 | if path.is_file() { |
| 110 | out.extend(discover_from_json_file( |
| 111 | path, |
| 112 | ExternalMcpSourceKind::Marketplace, |
| 113 | )); |
| 114 | } |
| 115 | } |
| 116 | out |
| 117 | } |
| 118 | |
| 119 | fn discover_from_json_file(path: &Path, kind: ExternalMcpSourceKind) -> Vec<ImportCandidate> { |
| 120 | let Ok(raw) = fs::read(path) else { |
| 121 | return Vec::new(); |
| 122 | }; |
| 123 | let hash = hex_sha256(&raw); |
| 124 | let Ok(value) = serde_json::from_slice::<Value>(&raw) else { |
| 125 | return Vec::new(); |
| 126 | }; |
| 127 | let servers = extract_servers_map(&value); |
| 128 | let mut out = Vec::with_capacity(servers.len()); |
| 129 | for (name, cfg_value) in servers { |
| 130 | let Ok(server) = serde_json::from_value::<McpServerConfig>(cfg_value.clone()) else { |
| 131 | continue; |
| 132 | }; |
| 133 | let hard_blocked = !server.is_enabled(); |
| 134 | let summary = server_summary(&name, &server); |
| 135 | out.push(ImportCandidate { |
| 136 | name, |
| 137 | source_kind: kind.clone(), |
| 138 | source_path: path.to_path_buf(), |
| 139 | content_hash: hash.clone(), |
| 140 | summary, |
| 141 | hard_blocked, |
| 142 | block_reason: hard_blocked.then(|| { |
| 143 | "enabled=false (or disabled=true) is a hard block — will not import".to_string() |
| 144 | }), |
| 145 | server, |
| 146 | }); |
| 147 | } |
| 148 | out |
| 149 | } |
| 150 | |
| 151 | fn extract_servers_map(value: &Value) -> Vec<(String, Value)> { |
| 152 | // Claude / team: { "mcpServers": { name: {...} } } |
| 153 | // Marketplace catalog: { "servers": { name: {...} } } or array of {name, ...} |
| 154 | if let Some(map) = value |
| 155 | .get("mcpServers") |
| 156 | .or_else(|| value.get("servers")) |
| 157 | .and_then(|v| v.as_object()) |
| 158 | { |
| 159 | return map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); |
| 160 | } |
| 161 | if let Some(arr) = value.as_array() { |
| 162 | let mut out = Vec::new(); |
| 163 | for item in arr { |
| 164 | let Some(name) = item.get("name").and_then(|v| v.as_str()) else { |
| 165 | continue; |
| 166 | }; |
| 167 | out.push((name.to_string(), item.clone())); |
| 168 | } |
| 169 | return out; |
| 170 | } |
| 171 | Vec::new() |
| 172 | } |
| 173 | |
| 174 | fn server_summary(name: &str, server: &McpServerConfig) -> String { |
| 175 | if let Some(url) = server.url.as_deref() { |
| 176 | return format!("{name} — http {url}"); |
| 177 | } |
| 178 | if let Some(cmd) = server.command.as_deref() { |
| 179 | let args = server.args.join(" "); |
| 180 | if args.is_empty() { |
| 181 | return format!("{name} — stdio {cmd}"); |
| 182 | } |
| 183 | return format!("{name} — stdio {cmd} {args}"); |
| 184 | } |
| 185 | format!("{name} — (incomplete server config)") |
| 186 | } |
| 187 | |
| 188 | fn hex_sha256(bytes: &[u8]) -> String { |
| 189 | let digest = Sha256::digest(bytes); |
| 190 | digest.iter().map(|b| format!("{b:02x}")).collect() |
| 191 | } |
| 192 | |
| 193 | /// Load consent store from disk (missing file → empty). |
| 194 | pub fn load_consent_store(path: &Path) -> ImportConsentStore { |
| 195 | let Ok(raw) = fs::read_to_string(path) else { |
| 196 | return ImportConsentStore::default(); |
| 197 | }; |
| 198 | serde_json::from_str(&raw).unwrap_or_default() |
| 199 | } |
| 200 | |
| 201 | /// Persist consent store atomically-ish (write then rename best-effort). |
| 202 | pub fn save_consent_store(path: &Path, store: &ImportConsentStore) -> std::io::Result<()> { |
| 203 | if let Some(parent) = path.parent() { |
| 204 | fs::create_dir_all(parent)?; |
| 205 | } |
| 206 | let raw = serde_json::to_string_pretty(store) |
| 207 | .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; |
| 208 | let tmp = path.with_extension("json.tmp"); |
| 209 | fs::write(&tmp, raw)?; |
| 210 | fs::rename(tmp, path)?; |
| 211 | Ok(()) |
| 212 | } |
| 213 | |
| 214 | /// Filter candidates that still need a user decision for this content hash. |
| 215 | #[allow(dead_code)] // used by future selector UI + unit tests |
| 216 | pub fn candidates_needing_consent( |
| 217 | candidates: &[ImportCandidate], |
| 218 | store: &ImportConsentStore, |
| 219 | ) -> Vec<ImportCandidate> { |
| 220 | candidates |
| 221 | .iter() |
| 222 | .filter(|c| { |
| 223 | let key = consent_key(&c.source_path, &c.content_hash); |
| 224 | match store.entries.get(&key) { |
| 225 | Some(entry) if entry.decision == ImportDecision::Decline => false, |
| 226 | Some(entry) if entry.decision == ImportDecision::Approve => { |
| 227 | // Re-prompt only when the specific server was not part of |
| 228 | // the prior approval list (partial approval). |
| 229 | !entry.servers.iter().any(|s| s == &c.name) |
| 230 | } |
| 231 | _ => true, |
| 232 | } |
| 233 | }) |
| 234 | .cloned() |
| 235 | .collect() |
| 236 | } |
| 237 | |
| 238 | /// Apply approvals: returns servers to merge into user mcp.json. |
| 239 | /// Hard-blocked candidates are never returned even if decision is Approve. |
| 240 | pub fn apply_approved( |
| 241 | candidates: &[ImportCandidate], |
| 242 | decisions: &HashMap<String, ImportDecision>, |
| 243 | ) -> Vec<(String, McpServerConfig, ImportCandidate)> { |
| 244 | let mut out = Vec::new(); |
| 245 | for candidate in candidates { |
| 246 | let decision = decisions |
| 247 | .get(&candidate.name) |
| 248 | .copied() |
| 249 | .unwrap_or(ImportDecision::Skip); |
| 250 | if decision != ImportDecision::Approve { |
| 251 | continue; |
| 252 | } |
| 253 | if candidate.hard_blocked { |
| 254 | continue; |
| 255 | } |
| 256 | out.push(( |
| 257 | candidate.name.clone(), |
| 258 | candidate.server.clone(), |
| 259 | candidate.clone(), |
| 260 | )); |
| 261 | } |
| 262 | out |
| 263 | } |
| 264 | |
| 265 | /// Record decisions in the consent store (including declines). |
| 266 | pub fn record_decisions( |
| 267 | store: &mut ImportConsentStore, |
| 268 | candidates: &[ImportCandidate], |
| 269 | decisions: &HashMap<String, ImportDecision>, |
| 270 | now_unix: u64, |
| 271 | ) { |
| 272 | // Group by source path + hash so one file approval is one entry. |
| 273 | let mut by_source: HashMap<(PathBuf, String), Vec<(&ImportCandidate, ImportDecision)>> = |
| 274 | HashMap::new(); |
| 275 | for candidate in candidates { |
| 276 | let decision = decisions |
| 277 | .get(&candidate.name) |
| 278 | .copied() |
| 279 | .unwrap_or(ImportDecision::Skip); |
| 280 | if decision == ImportDecision::Skip { |
| 281 | continue; |
| 282 | } |
| 283 | by_source |
| 284 | .entry(( |
| 285 | candidate.source_path.clone(), |
| 286 | candidate.content_hash.clone(), |
| 287 | )) |
| 288 | .or_default() |
| 289 | .push((candidate, decision)); |
| 290 | } |
| 291 | for ((path, hash), group) in by_source { |
| 292 | // If any approval exists, record Approve with the approved names; |
| 293 | // pure decline groups record Decline. |
| 294 | let any_approve = group.iter().any(|(_, d)| *d == ImportDecision::Approve); |
| 295 | let decision = if any_approve { |
| 296 | ImportDecision::Approve |
| 297 | } else { |
| 298 | ImportDecision::Decline |
| 299 | }; |
| 300 | let servers: Vec<String> = group |
| 301 | .iter() |
| 302 | .filter(|(_, d)| *d == ImportDecision::Approve) |
| 303 | .filter(|(c, _)| !c.hard_blocked) |
| 304 | .map(|(c, _)| c.name.clone()) |
| 305 | .collect(); |
| 306 | let key = consent_key(&path, &hash); |
| 307 | store.entries.insert( |
| 308 | key, |
| 309 | ConsentEntry { |
| 310 | source_path: path.display().to_string(), |
| 311 | content_hash: hash, |
| 312 | decision, |
| 313 | decided_at_unix: now_unix, |
| 314 | servers, |
| 315 | }, |
| 316 | ); |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | /// Merge approved servers into an existing McpConfig. Does not touch |
| 321 | /// hard-blocked entries. Returns names that were newly inserted. |
| 322 | pub fn merge_approved_into_config( |
| 323 | config: &mut McpConfig, |
| 324 | approved: &[(String, McpServerConfig, ImportCandidate)], |
| 325 | ) -> Vec<String> { |
| 326 | let mut inserted = Vec::new(); |
| 327 | for (name, server, _) in approved { |
| 328 | if config.servers.contains_key(name) { |
| 329 | continue; |
| 330 | } |
| 331 | // Defense in depth: never insert disabled servers. |
| 332 | if !server.is_enabled() { |
| 333 | continue; |
| 334 | } |
| 335 | config.servers.insert(name.clone(), server.clone()); |
| 336 | inserted.push(name.clone()); |
| 337 | } |
| 338 | inserted |
| 339 | } |
| 340 | |
| 341 | /// Human-readable provenance block for the selector / status panel. |
| 342 | pub fn format_candidates_for_display(candidates: &[ImportCandidate]) -> String { |
| 343 | if candidates.is_empty() { |
| 344 | return "No external MCP sources found (or all already decided for current content)." |
| 345 | .to_string(); |
| 346 | } |
| 347 | let mut lines = vec![ |
| 348 | "External MCP import candidates (nothing is installed until you approve):".to_string(), |
| 349 | String::new(), |
| 350 | ]; |
| 351 | for (idx, c) in candidates.iter().enumerate() { |
| 352 | let status = if c.hard_blocked { "BLOCKED" } else { "pending" }; |
| 353 | lines.push(format!( |
| 354 | " {}. [{}] {} — provenance: {} ({})", |
| 355 | idx + 1, |
| 356 | status, |
| 357 | c.summary, |
| 358 | c.source_kind.as_str(), |
| 359 | c.source_path.display() |
| 360 | )); |
| 361 | lines.push(format!( |
| 362 | " content_hash: {}", |
| 363 | &c.content_hash[..12.min(c.content_hash.len())] |
| 364 | )); |
| 365 | if let Some(reason) = &c.block_reason { |
| 366 | lines.push(format!(" {reason}")); |
| 367 | } |
| 368 | } |
| 369 | lines.push(String::new()); |
| 370 | lines.push( |
| 371 | "Approve one: /mcp import approve <name> · Decline source: /mcp import decline <name> · List: /mcp import" |
| 372 | .to_string(), |
| 373 | ); |
| 374 | lines.join("\n") |
| 375 | } |
| 376 | |
| 377 | #[cfg(test)] |
| 378 | mod tests { |
| 379 | use super::*; |
| 380 | use tempfile::tempdir; |
| 381 | |
| 382 | fn write_claude_json(dir: &Path, body: &str) -> PathBuf { |
| 383 | let path = dir.join(".claude.json"); |
| 384 | fs::write(&path, body).unwrap(); |
| 385 | path |
| 386 | } |
| 387 | |
| 388 | #[test] |
| 389 | fn disabled_imported_server_never_merges() { |
| 390 | let dir = tempdir().unwrap(); |
| 391 | let body = r#"{ |
| 392 | "mcpServers": { |
| 393 | "ok": { "command": "npx", "args": ["-y", "good"], "enabled": true }, |
| 394 | "blocked": { "command": "npx", "args": ["-y", "bad"], "enabled": false } |
| 395 | } |
| 396 | }"#; |
| 397 | write_claude_json(dir.path(), body); |
| 398 | let candidates = discover_external_sources(dir.path(), dir.path(), &[]); |
| 399 | assert_eq!(candidates.len(), 2); |
| 400 | let blocked = candidates.iter().find(|c| c.name == "blocked").unwrap(); |
| 401 | assert!(blocked.hard_blocked); |
| 402 | |
| 403 | let mut decisions = HashMap::new(); |
| 404 | decisions.insert("ok".into(), ImportDecision::Approve); |
| 405 | decisions.insert("blocked".into(), ImportDecision::Approve); |
| 406 | let approved = apply_approved(&candidates, &decisions); |
| 407 | assert_eq!(approved.len(), 1); |
| 408 | assert_eq!(approved[0].0, "ok"); |
| 409 | |
| 410 | let mut config = McpConfig::default(); |
| 411 | let inserted = merge_approved_into_config(&mut config, &approved); |
| 412 | assert_eq!(inserted, vec!["ok".to_string()]); |
| 413 | assert!(!config.servers.contains_key("blocked")); |
| 414 | assert!(config.servers["ok"].is_enabled()); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn declined_consent_skips_reprompt_until_hash_changes() { |
| 419 | let dir = tempdir().unwrap(); |
| 420 | let path = write_claude_json( |
| 421 | dir.path(), |
| 422 | r#"{"mcpServers":{"x":{"command":"echo","enabled":true}}}"#, |
| 423 | ); |
| 424 | let candidates = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson); |
| 425 | let mut store = ImportConsentStore::default(); |
| 426 | let mut decisions = HashMap::new(); |
| 427 | decisions.insert("x".into(), ImportDecision::Decline); |
| 428 | record_decisions(&mut store, &candidates, &decisions, 1); |
| 429 | let needing = candidates_needing_consent(&candidates, &store); |
| 430 | assert!(needing.is_empty(), "declined should not re-prompt"); |
| 431 | |
| 432 | // Content change → new hash → re-prompt. |
| 433 | fs::write( |
| 434 | &path, |
| 435 | r#"{"mcpServers":{"x":{"command":"echo","args":["changed"],"enabled":true}}}"#, |
| 436 | ) |
| 437 | .unwrap(); |
| 438 | let refreshed = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson); |
| 439 | let needing = candidates_needing_consent(&refreshed, &store); |
| 440 | assert_eq!(needing.len(), 1); |
| 441 | } |
| 442 | |
| 443 | #[test] |
| 444 | fn provenance_display_includes_source_and_hash() { |
| 445 | let dir = tempdir().unwrap(); |
| 446 | write_claude_json( |
| 447 | dir.path(), |
| 448 | r#"{"mcpServers":{"hf":{"url":"https://example.com/mcp","enabled":true}}}"#, |
| 449 | ); |
| 450 | let candidates = discover_external_sources(dir.path(), dir.path(), &[]); |
| 451 | let text = format_candidates_for_display(&candidates); |
| 452 | assert!(text.contains("provenance:")); |
| 453 | assert!(text.contains("claude.json")); |
| 454 | assert!(text.contains("content_hash:")); |
| 455 | assert!(text.contains("nothing is installed until you approve")); |
| 456 | } |
| 457 | |
| 458 | #[test] |
| 459 | fn project_mcp_json_and_marketplace_are_discovered() { |
| 460 | let home = tempdir().unwrap(); |
| 461 | let workspace = tempdir().unwrap(); |
| 462 | fs::write( |
| 463 | workspace.path().join(".mcp.json"), |
| 464 | r#"{"mcpServers":{"team":{"command":"uvx","args":["team-mcp"]}}}"#, |
| 465 | ) |
| 466 | .unwrap(); |
| 467 | let market = home.path().join("market.json"); |
| 468 | fs::write( |
| 469 | &market, |
| 470 | r#"{"servers":{"shop":{"url":"https://market.example/mcp"}}}"#, |
| 471 | ) |
| 472 | .unwrap(); |
| 473 | let candidates = discover_external_sources(home.path(), workspace.path(), &[market]); |
| 474 | let names: Vec<_> = candidates.iter().map(|c| c.name.as_str()).collect(); |
| 475 | assert!(names.contains(&"team")); |
| 476 | assert!(names.contains(&"shop")); |
| 477 | } |
| 478 | } |
| 479 |