返回 CodeWhale
external_import.rs
根目录 / crates / tui / src / mcp / external_import.rs
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
479 lines RUST