返回 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. Import approval saves connectors OFF; a separate enable action
5 //! is required before any connection. Provenance
6 //! (source path + content hash) is shown before import. `enabled=false` and
7 //! `disabled=true` on a source entry are hard blocks — those candidates never
8 //! become managed connectors even after a blanket approval.
9 //!
10 //! Design (Kimi session_a75a393a-a984-4f35-98d0-b78cfbdcf23f): keep discovery
11 //! pure and independent of the TUI; merge approved servers through the same
12 //! config write path as `/mcp add`.
13
14 use std::collections::HashMap;
15 #[cfg(test)]
16 use std::fs;
17 use std::path::{Path, PathBuf};
18
19 use serde::{Deserialize, Serialize};
20 use serde_json::Value;
21 use sha2::{Digest, Sha256};
22
23 use super::{McpConfig, McpServerConfig};
24
25 /// Where an import candidate came from.
26 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27 #[serde(rename_all = "snake_case")]
28 pub enum ExternalMcpSourceKind {
29 ClaudeJson,
30 ProjectMcpJson,
31 Marketplace,
32 }
33
34 impl ExternalMcpSourceKind {
35 #[must_use]
36 pub fn as_str(&self) -> &'static str {
37 match self {
38 Self::ClaudeJson => "claude.json",
39 Self::ProjectMcpJson => ".mcp.json",
40 Self::Marketplace => "marketplace",
41 }
42 }
43 }
44
45 /// One discovered server before consent.
46 #[derive(Debug, Clone, Serialize, Deserialize)]
47 pub struct ImportCandidate {
48 pub name: String,
49 pub source_kind: ExternalMcpSourceKind,
50 pub source_path: PathBuf,
51 /// Hex sha256 of this server's name and entry as parsed from the source.
52 /// It covers exactly what an approval imports, so review tokens and
53 /// decisions survive unrelated rewrites of the same file (Claude Code
54 /// rewrites `~/.claude.json` on nearly every run).
55 pub content_hash: String,
56 pub summary: String,
57 /// When true the entry is present but must never connect.
58 pub hard_blocked: bool,
59 pub block_reason: Option<String>,
60 pub server: McpServerConfig,
61 }
62
63 /// User decision for one candidate.
64 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65 #[serde(rename_all = "snake_case")]
66 pub enum ImportDecision {
67 Approve,
68 Decline,
69 Skip,
70 }
71
72 /// Durable consent / decline record keyed by source path + hash.
73 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
74 pub struct ImportConsentStore {
75 #[serde(default)]
76 pub entries: HashMap<String, ConsentEntry>,
77 }
78
79 #[derive(Debug, Clone, Serialize, Deserialize)]
80 pub struct ConsentEntry {
81 pub source_path: String,
82 pub content_hash: String,
83 pub decision: ImportDecision,
84 pub decided_at_unix: u64,
85 pub servers: Vec<String>,
86 }
87
88 fn consent_key(path: &Path, hash: &str) -> String {
89 format!("{}::{hash}", path.display())
90 }
91
92 /// Discover candidates from well-known external locations. Never connects.
93 pub fn discover_external_sources(
94 home: &Path,
95 workspace: &Path,
96 marketplace_paths: &[PathBuf],
97 ) -> Vec<ImportCandidate> {
98 let mut out = Vec::new();
99 let claude = home.join(".claude.json");
100 if claude.is_file() {
101 out.extend(discover_from_json_file(
102 &claude,
103 ExternalMcpSourceKind::ClaudeJson,
104 ));
105 }
106 let project_mcp = workspace.join(".mcp.json");
107 if project_mcp.is_file() {
108 out.extend(discover_from_json_file(
109 &project_mcp,
110 ExternalMcpSourceKind::ProjectMcpJson,
111 ));
112 }
113 for path in marketplace_paths {
114 if path.is_file() {
115 out.extend(discover_from_json_file(
116 path,
117 ExternalMcpSourceKind::Marketplace,
118 ));
119 }
120 }
121 out
122 }
123
124 fn discover_from_json_file(path: &Path, kind: ExternalMcpSourceKind) -> Vec<ImportCandidate> {
125 checked_source(path, kind)
126 .map(|(candidates, _)| candidates)
127 .unwrap_or_default()
128 }
129
130 /// Most per-entry problems reported for one source; the rest are counted.
131 const MAX_PROBLEMS_PER_SOURCE: usize = 20;
132
133 /// Read one source. A file-level failure is an `Err`; an entry that fails
134 /// validation is reported in the second list (content-free) and skipped, so
135 /// one unsupported entry cannot hide its valid siblings. At most
136 /// [`MAX_PROBLEMS_PER_SOURCE`] entries are named, then one summary line.
137 fn checked_source(
138 path: &Path,
139 kind: ExternalMcpSourceKind,
140 ) -> anyhow::Result<(Vec<ImportCandidate>, Vec<String>)> {
141 super::validate_mcp_config_path(path)?;
142 // `~/.claude.json` is Claude Code's whole state file (project history,
143 // caches), routinely past the 1 MiB MCP config bound. Use the bound
144 // `/import-claude` already reads it with.
145 let max_bytes = match kind {
146 ExternalMcpSourceKind::ClaudeJson => crate::import_claude::MAX_SOURCE_BYTES,
147 _ => super::MAX_MCP_CONFIG_BYTES,
148 };
149 let Some(raw) = super::read_bounded_mcp_config_file(path, max_bytes)? else {
150 return Ok((Vec::new(), Vec::new()));
151 };
152 let value: Value = serde_json::from_str(&raw)
153 .map_err(|_| anyhow::anyhow!("Source is not valid JSON; contents omitted"))?;
154 anyhow::ensure!(
155 value
156 .get("mcpServers")
157 .or_else(|| value.get("servers"))
158 .is_some_and(Value::is_object)
159 || value.is_array(),
160 "Source has no supported MCP server map"
161 );
162 let mut out = Vec::new();
163 let mut problems = Vec::new();
164 let mut unreported = 0usize;
165 let mut report = |problem: String| {
166 if problems.len() < MAX_PROBLEMS_PER_SOURCE {
167 problems.push(problem);
168 } else {
169 unreported += 1;
170 }
171 };
172 for (name, config) in extract_servers_map(&value) {
173 if !(super::mcp_name_is_command_safe(&name) && name.len() <= 128) {
174 report("An entry with an unsupported server name was skipped".to_string());
175 continue;
176 }
177 let hash = entry_hash(&name, &config);
178 let server = match checked_entry(config, value.is_array()) {
179 Ok(server) => server,
180 Err(error) => {
181 report(format!("Server '{name}' was skipped: {error}"));
182 continue;
183 }
184 };
185 let hard_blocked = !server.is_enabled();
186 out.push(ImportCandidate {
187 summary: server_summary(&name, &server),
188 name,
189 source_kind: kind.clone(),
190 source_path: path.to_path_buf(),
191 content_hash: hash,
192 hard_blocked,
193 block_reason: hard_blocked.then(|| "Disabled at its source; cannot import".into()),
194 server,
195 });
196 }
197 if unreported > 0 {
198 problems.push(format!("{unreported} more entries were skipped"));
199 }
200 Ok((out, problems))
201 }
202
203 fn entry_hash(name: &str, entry: &Value) -> String {
204 let mut bytes = name.as_bytes().to_vec();
205 bytes.push(0);
206 bytes.extend(entry.to_string().into_bytes());
207 hex_sha256(&bytes)
208 }
209
210 /// Validate one server entry. Error messages never echo entry contents.
211 fn checked_entry(mut config: Value, from_array: bool) -> anyhow::Result<McpServerConfig> {
212 if let Some(map) = config.as_object_mut() {
213 if from_array {
214 map.remove("name");
215 }
216 translate_entry_type(map)?;
217 }
218 let fields = config
219 .as_object()
220 .ok_or_else(|| anyhow::anyhow!("Invalid MCP entry; contents omitted"))?;
221 const ALLOWED: &[&str] = &[
222 "command",
223 "args",
224 "env",
225 "cwd",
226 "url",
227 "allow_private_network",
228 "transport",
229 "connect_timeout",
230 "execute_timeout",
231 "read_timeout",
232 "disabled",
233 "enabled",
234 "required",
235 "enabled_tools",
236 "disabled_tools",
237 "headers",
238 "env_headers",
239 "env_http_headers",
240 "bearer_token_env_var",
241 "scopes",
242 "oauth",
243 "oauth_resource",
244 ];
245 anyhow::ensure!(
246 fields.keys().all(|key| ALLOWED.contains(&key.as_str())),
247 "Entry contains unsupported MCP fields; review it at its source"
248 );
249 if let Some(oauth) = fields.get("oauth").filter(|v| !v.is_null()) {
250 anyhow::ensure!(
251 oauth
252 .as_object()
253 .is_some_and(|map| map.keys().all(|key| key == "client_id")),
254 "Entry contains unsupported OAuth fields"
255 );
256 }
257 let server: McpServerConfig = serde_json::from_value(config)
258 .map_err(|_| anyhow::anyhow!("Invalid MCP entry; contents omitted"))?;
259 anyhow::ensure!(
260 server.command.is_some() != server.url.is_some(),
261 "MCP entry must have one target"
262 );
263 if let Some(command) = &server.command {
264 anyhow::ensure!(
265 !command.trim().is_empty() && !command.chars().any(char::is_control),
266 "Invalid MCP command"
267 );
268 }
269 if let Some(url) = &server.url {
270 let parsed = reqwest::Url::parse(url).map_err(|_| anyhow::anyhow!("Invalid MCP URL"))?;
271 anyhow::ensure!(
272 matches!(parsed.scheme(), "http" | "https")
273 && parsed.host_str().is_some()
274 && parsed.username().is_empty()
275 && parsed.password().is_none(),
276 "Unsupported MCP URL"
277 );
278 }
279 super::validate_mcp_transport(server.transport.as_deref())
280 .map_err(|_| anyhow::anyhow!("Unsupported MCP transport"))?;
281 Ok(server)
282 }
283
284 /// Claude Code writes `"type": "stdio" | "http" | "sse"` on every entry. Fold
285 /// it into Codewhale's shape: the target field already selects stdio versus
286 /// HTTP, and only legacy SSE needs an explicit `transport`. A `type` that
287 /// contradicts the entry's target or transport is refused, never guessed.
288 fn translate_entry_type(map: &mut serde_json::Map<String, Value>) -> anyhow::Result<()> {
289 let Some(kind) = map.remove("type") else {
290 return Ok(());
291 };
292 let kind = kind
293 .as_str()
294 .map(|kind| kind.trim().to_ascii_lowercase())
295 .ok_or_else(|| anyhow::anyhow!("Unsupported MCP entry type"))?;
296 let transport = map
297 .get("transport")
298 .map(|transport| transport.as_str().map(str::trim));
299 let is_sse = transport.is_some_and(|t| t.is_some_and(|t| t.eq_ignore_ascii_case("sse")));
300 match kind.as_str() {
301 "stdio" => anyhow::ensure!(
302 map.contains_key("command") && transport.is_none(),
303 "MCP entry type does not match its target"
304 ),
305 "http" | "streamable-http" => anyhow::ensure!(
306 map.contains_key("url") && !is_sse,
307 "MCP entry type does not match its target"
308 ),
309 "sse" => {
310 anyhow::ensure!(
311 map.contains_key("url") && (transport.is_none() || is_sse),
312 "MCP entry type does not match its target"
313 );
314 map.insert("transport".to_string(), Value::String("sse".to_string()));
315 }
316 _ => anyhow::bail!("Unsupported MCP entry type"),
317 }
318 Ok(())
319 }
320
321 fn extract_servers_map(value: &Value) -> Vec<(String, Value)> {
322 // Claude / team: { "mcpServers": { name: {...} } }
323 // Marketplace catalog: { "servers": { name: {...} } } or array of {name, ...}
324 if let Some(map) = value
325 .get("mcpServers")
326 .or_else(|| value.get("servers"))
327 .and_then(|v| v.as_object())
328 {
329 return map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
330 }
331 if let Some(arr) = value.as_array() {
332 let mut out = Vec::new();
333 for item in arr {
334 let Some(name) = item.get("name").and_then(|v| v.as_str()) else {
335 continue;
336 };
337 out.push((name.to_string(), item.clone()));
338 }
339 return out;
340 }
341 Vec::new()
342 }
343
344 fn destination(server: &McpServerConfig) -> String {
345 if let Some(url) = &server.url {
346 return reqwest::Url::parse(url)
347 .map(|url| url.origin().ascii_serialization())
348 .unwrap_or_else(|_| "Invalid URL".into());
349 }
350 server
351 .command
352 .as_deref()
353 .and_then(|command| Path::new(command).file_name())
354 .map(|name| name.to_string_lossy().into_owned())
355 .unwrap_or_else(|| "Unknown command".into())
356 }
357
358 fn server_summary(name: &str, server: &McpServerConfig) -> String {
359 format!(
360 "{name} — {} ({} arguments; credential values hidden)",
361 destination(server),
362 server.args.len()
363 )
364 }
365
366 fn hex_sha256(bytes: &[u8]) -> String {
367 let digest = Sha256::digest(bytes);
368 digest.iter().map(|b| format!("{b:02x}")).collect()
369 }
370
371 /// Record decisions against the latest consent document under the shared
372 /// process lock. Malformed history is never silently replaced with empty state.
373 pub fn persist_decisions(
374 path: &Path,
375 candidates: &[ImportCandidate],
376 decisions: &HashMap<String, ImportDecision>,
377 now_unix: u64,
378 ) -> anyhow::Result<()> {
379 super::validate_mcp_config_path(path)?;
380 codewhale_config::with_config_write_lock(path, |path| {
381 let original = super::read_mcp_config_file(path)?;
382 let mut raw: Value = match original.as_deref() {
383 Some(raw) => serde_json::from_str(raw)
384 .map_err(|_| anyhow::anyhow!("Invalid MCP consent history; contents omitted"))?,
385 None => serde_json::json!({}),
386 };
387 anyhow::ensure!(raw.is_object(), "MCP consent history must be an object");
388 let mut store: ImportConsentStore = if original.is_none() {
389 ImportConsentStore::default()
390 } else {
391 serde_json::from_value(raw.clone())
392 .map_err(|_| anyhow::anyhow!("Invalid MCP consent history; contents omitted"))?
393 };
394 let before = serde_json::to_value(&store)?;
395 record_decisions(&mut store, candidates, decisions, now_unix);
396 let after = serde_json::to_value(&store)?;
397 super::apply_json_delta(&mut raw, &before, &after);
398 let rendered = serde_json::to_vec_pretty(&raw)?;
399 if rendered.len() as u64 > super::MAX_MCP_CONFIG_BYTES {
400 anyhow::bail!("MCP consent history exceeds size limit");
401 }
402 crate::utils::write_atomic(path, &rendered)?;
403 Ok(())
404 })
405 }
406
407 /// Filter candidates that still need a user decision for this content hash.
408 #[allow(dead_code)] // used by future selector UI + unit tests
409 pub fn candidates_needing_consent(
410 candidates: &[ImportCandidate],
411 store: &ImportConsentStore,
412 ) -> Vec<ImportCandidate> {
413 candidates
414 .iter()
415 .filter(|c| {
416 let key = consent_key(&c.source_path, &c.content_hash);
417 match store.entries.get(&key) {
418 Some(entry) if entry.decision == ImportDecision::Decline => false,
419 Some(entry) if entry.decision == ImportDecision::Approve => {
420 // Re-prompt only when the specific server was not part of
421 // the prior approval list (partial approval).
422 !entry.servers.iter().any(|s| s == &c.name)
423 }
424 _ => true,
425 }
426 })
427 .cloned()
428 .collect()
429 }
430
431 /// Apply approvals: returns servers to merge into user mcp.json.
432 /// Hard-blocked candidates are never returned even if decision is Approve.
433 #[cfg(test)]
434 pub fn apply_approved(
435 candidates: &[ImportCandidate],
436 decisions: &HashMap<String, ImportDecision>,
437 ) -> Vec<(String, McpServerConfig, ImportCandidate)> {
438 let mut out = Vec::new();
439 for candidate in candidates {
440 let decision = decisions
441 .get(&candidate.name)
442 .copied()
443 .unwrap_or(ImportDecision::Skip);
444 if decision != ImportDecision::Approve {
445 continue;
446 }
447 if candidate.hard_blocked {
448 continue;
449 }
450 out.push((
451 candidate.name.clone(),
452 candidate.server.clone(),
453 candidate.clone(),
454 ));
455 }
456 out
457 }
458
459 /// Record decisions in the consent store (including declines).
460 pub fn record_decisions(
461 store: &mut ImportConsentStore,
462 candidates: &[ImportCandidate],
463 decisions: &HashMap<String, ImportDecision>,
464 now_unix: u64,
465 ) {
466 // Group by source path + entry hash: one record per reviewed entry.
467 let mut by_source: HashMap<(PathBuf, String), Vec<(&ImportCandidate, ImportDecision)>> =
468 HashMap::new();
469 for candidate in candidates {
470 let decision = decisions
471 .get(&candidate.name)
472 .copied()
473 .unwrap_or(ImportDecision::Skip);
474 if decision == ImportDecision::Skip {
475 continue;
476 }
477 by_source
478 .entry((
479 candidate.source_path.clone(),
480 candidate.content_hash.clone(),
481 ))
482 .or_default()
483 .push((candidate, decision));
484 }
485 for ((path, hash), group) in by_source {
486 // If any approval exists, record Approve with the approved names;
487 // pure decline groups record Decline.
488 let any_approve = group.iter().any(|(_, d)| *d == ImportDecision::Approve);
489 let decision = if any_approve {
490 ImportDecision::Approve
491 } else {
492 ImportDecision::Decline
493 };
494 let servers: Vec<String> = group
495 .iter()
496 .filter(|(_, d)| *d == ImportDecision::Approve)
497 .filter(|(c, _)| !c.hard_blocked)
498 .map(|(c, _)| c.name.clone())
499 .collect();
500 let key = consent_key(&path, &hash);
501 store.entries.insert(
502 key,
503 ConsentEntry {
504 source_path: path.display().to_string(),
505 content_hash: hash,
506 decision,
507 decided_at_unix: now_unix,
508 servers,
509 },
510 );
511 }
512 }
513
514 /// Merge approved servers into an existing McpConfig. Does not touch
515 /// hard-blocked entries. Returns names that were newly inserted.
516 #[cfg(test)]
517 pub fn merge_approved_into_config(
518 config: &mut McpConfig,
519 approved: &[(String, McpServerConfig, ImportCandidate)],
520 ) -> Vec<String> {
521 let mut inserted = Vec::new();
522 for (name, server, _) in approved {
523 if config.servers.contains_key(name) {
524 continue;
525 }
526 // Defense in depth: never insert disabled servers.
527 if !server.is_enabled() {
528 continue;
529 }
530 let mut server = server.clone();
531 server.enabled = false;
532 server.disabled = true;
533 config.servers.insert(name.clone(), server);
534 inserted.push(name.clone());
535 }
536 inserted
537 }
538
539 /// Human-readable provenance block for the selector / status panel.
540 #[cfg(test)]
541 pub fn format_candidates_for_display(candidates: &[ImportCandidate]) -> String {
542 if candidates.is_empty() {
543 return "No external MCP sources found (or all already decided for current content)."
544 .to_string();
545 }
546 let mut lines = vec![
547 "External MCP import candidates (nothing is installed until you approve):".to_string(),
548 String::new(),
549 ];
550 for (idx, c) in candidates.iter().enumerate() {
551 let status = if c.hard_blocked { "BLOCKED" } else { "pending" };
552 lines.push(format!(
553 " {}. [{}] {} — provenance: {} ({})",
554 idx + 1,
555 status,
556 c.summary,
557 c.source_kind.as_str(),
558 c.source_path.display()
559 ));
560 lines.push(format!(
561 " content_hash: {}",
562 &c.content_hash[..12.min(c.content_hash.len())]
563 ));
564 if let Some(reason) = &c.block_reason {
565 lines.push(format!(" {reason}"));
566 }
567 }
568 lines.push(String::new());
569 lines.push(
570 "Run /mcp import for a current reviewed approval token. Imports stay off until enabled separately."
571 .to_string(),
572 );
573 lines.join("\n")
574 }
575
576 /// One authority shared by the native API and terminal import UI. Sources are
577 /// selected here; callers cannot supply arbitrary source paths to the API.
578 pub struct ImportContext<'a> {
579 pub workspace: &'a Path,
580 pub mcp_path: &'a Path,
581 pub plugins: &'a crate::plugins::PluginRegistry,
582 pub home: PathBuf,
583 pub codewhale_home: PathBuf,
584 }
585 impl<'a> ImportContext<'a> {
586 pub fn new(
587 workspace: &'a Path,
588 mcp_path: &'a Path,
589 plugins: &'a crate::plugins::PluginRegistry,
590 ) -> anyhow::Result<Self> {
591 Ok(Self {
592 workspace,
593 mcp_path,
594 plugins,
595 home: crate::config::effective_home_dir()
596 .ok_or_else(|| anyhow::anyhow!("Home directory unavailable"))?,
597 codewhale_home: codewhale_config::codewhale_home()?,
598 })
599 }
600 fn discover(&self) -> (Vec<ImportCandidate>, Vec<ImportProblem>) {
601 let sources = [
602 (
603 self.home.join(".claude.json"),
604 ExternalMcpSourceKind::ClaudeJson,
605 ),
606 (
607 self.workspace.join(".mcp.json"),
608 ExternalMcpSourceKind::ProjectMcpJson,
609 ),
610 (
611 self.codewhale_home.join("mcp-marketplace.json"),
612 ExternalMcpSourceKind::Marketplace,
613 ),
614 ];
615 let mut candidates = Vec::new();
616 let mut problems = Vec::new();
617 for (path, kind) in sources {
618 match checked_source(&path, kind.clone()) {
619 Ok((found, skipped)) => {
620 candidates.extend(found);
621 problems.extend(skipped.into_iter().map(|message| ImportProblem {
622 source_kind: kind.clone(),
623 message,
624 }));
625 }
626 Err(_) => problems.push(ImportProblem { source_kind: kind,
627 message: "Source could not be safely read or contains unsupported configuration; review it at its source".into() }),
628 }
629 }
630 (candidates, problems)
631 }
632 fn merged(&self) -> anyhow::Result<McpConfig> {
633 super::load_config_with_workspace_and_plugins(self.mcp_path, self.workspace, self.plugins)
634 }
635 }
636
637 #[derive(Debug, Serialize)]
638 pub struct ImportProblem {
639 pub source_kind: ExternalMcpSourceKind,
640 pub message: String,
641 }
642 #[derive(Debug, Serialize)]
643 pub struct ReviewedImport {
644 pub id: String,
645 pub name: String,
646 pub source_kind: ExternalMcpSourceKind,
647 pub source_path: PathBuf,
648 pub content_hash: String,
649 pub transport: &'static str,
650 pub destination: String,
651 pub argument_count: usize,
652 pub env_keys: Vec<String>,
653 pub header_keys: Vec<String>,
654 pub credential_configured: bool,
655 pub hard_blocked: bool,
656 pub conflict: bool,
657 pub review_token: String,
658 }
659 #[derive(Debug, Serialize)]
660 pub struct ImportPreview {
661 pub revision: String,
662 pub candidates: Vec<ReviewedImport>,
663 pub problems: Vec<ImportProblem>,
664 }
665 #[derive(Debug, Serialize)]
666 pub struct ImportReceipt {
667 pub name: String,
668 pub decision: ImportDecision,
669 pub imported: bool,
670 pub enabled: bool,
671 pub revision: String,
672 pub consent_recorded: bool,
673 pub warning: Option<String>,
674 }
675 fn candidate_id(candidate: &ImportCandidate) -> String {
676 hex_sha256(
677 format!(
678 "{}\0{}\0{}",
679 candidate.source_kind.as_str(),
680 candidate.source_path.display(),
681 candidate.name
682 )
683 .as_bytes(),
684 )
685 }
686 fn source_blocked(context: &ImportContext<'_>, candidate: &ImportCandidate) -> bool {
687 candidate.hard_blocked
688 || (candidate.source_kind == ExternalMcpSourceKind::ProjectMcpJson
689 && !crate::config::is_workspace_trusted(context.workspace))
690 }
691
692 pub fn preview_imports(context: &ImportContext<'_>) -> anyhow::Result<ImportPreview> {
693 codewhale_config::with_config_write_lock(context.mcp_path, |path| {
694 let revision = super::read_config_revision(path)?;
695 let merged = context.merged()?;
696 let (candidates, problems) = context.discover();
697 let candidates = candidates
698 .into_iter()
699 .map(|candidate| {
700 let id = candidate_id(&candidate);
701 let server = &candidate.server;
702 let mut env_keys: Vec<_> = server.env.keys().cloned().collect();
703 env_keys.sort();
704 let mut header_keys: Vec<_> = server
705 .headers
706 .keys()
707 .chain(server.env_headers.keys())
708 .cloned()
709 .collect();
710 header_keys.sort();
711 header_keys.dedup();
712 ReviewedImport {
713 review_token: format!(
714 "mcp-import-v1:{id}:{}:{revision}",
715 candidate.content_hash
716 ),
717 id,
718 transport: if server.url.is_some() {
719 "http"
720 } else {
721 "stdio"
722 },
723 destination: destination(server),
724 argument_count: server.args.len(),
725 env_keys,
726 header_keys,
727 credential_configured: !server.env.is_empty()
728 || !server.headers.is_empty()
729 || !server.env_headers.is_empty()
730 || server.bearer_token_env_var.is_some()
731 || server.oauth.is_some()
732 || server.oauth_resource.is_some(),
733 hard_blocked: source_blocked(context, &candidate),
734 conflict: merged.servers.contains_key(&candidate.name),
735 name: candidate.name,
736 source_kind: candidate.source_kind,
737 source_path: candidate.source_path,
738 content_hash: candidate.content_hash,
739 }
740 })
741 .collect();
742 Ok(ImportPreview {
743 revision,
744 candidates,
745 problems,
746 })
747 })
748 }
749
750 /// Re-read exact reviewed bytes inside the same config transaction as insertion.
751 /// Nothing connects here. Consent follows a successful write and cannot turn a
752 /// completed import into a false failed-write receipt.
753 pub fn apply_reviewed_import(
754 context: &ImportContext<'_>,
755 id: &str,
756 hash: &str,
757 revision: &str,
758 decision: ImportDecision,
759 ) -> anyhow::Result<ImportReceipt> {
760 anyhow::ensure!(
761 matches!(decision, ImportDecision::Approve | ImportDecision::Decline),
762 "Choose approve or decline"
763 );
764 let (candidate, revision) = super::mutate_config(context.mcp_path, Some(revision), |config| {
765 let (candidates, _) = context.discover();
766 let candidate = candidates
767 .into_iter()
768 .find(|candidate| candidate_id(candidate) == id)
769 .ok_or_else(|| {
770 anyhow::anyhow!("Reviewed source is unavailable; refresh the import preview")
771 })?;
772 anyhow::ensure!(
773 candidate.content_hash == hash,
774 "Source changed; refresh the import preview"
775 );
776 if decision == ImportDecision::Approve {
777 anyhow::ensure!(
778 !source_blocked(context, &candidate),
779 "Source is disabled or its workspace is untrusted"
780 );
781 anyhow::ensure!(
782 !context.merged()?.servers.contains_key(&candidate.name)
783 && !config.servers.contains_key(&candidate.name),
784 "A managed, project or plugin connector already uses this name"
785 );
786 let mut server = candidate.server.clone();
787 server.enabled = false;
788 server.disabled = true;
789 config.servers.insert(candidate.name.clone(), server);
790 }
791 Ok(candidate)
792 })?;
793 let decisions = HashMap::from([(candidate.name.clone(), decision)]);
794 let now = std::time::SystemTime::now()
795 .duration_since(std::time::UNIX_EPOCH)
796 .map_or(0, |time| time.as_secs());
797 let consent_recorded = persist_decisions(
798 &context.codewhale_home.join("mcp-import-consent.json"),
799 std::slice::from_ref(&candidate),
800 &decisions,
801 now,
802 )
803 .is_ok();
804 Ok(ImportReceipt { name: candidate.name, decision, imported: decision == ImportDecision::Approve,
805 enabled: false, revision, consent_recorded,
806 warning: (!consent_recorded).then(|| "The decision could not be added to import history; the configuration receipt above is authoritative".into()),
807 })
808 }
809
810 pub fn parse_review_token(token: &str) -> anyhow::Result<(&str, &str, &str)> {
811 let parts: Vec<_> = token.split(':').collect();
812 anyhow::ensure!(
813 parts.len() == 4
814 && parts[0] == "mcp-import-v1"
815 && parts[1..3]
816 .iter()
817 .all(|value| value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()))
818 && (parts[3] == "mcp-v1-absent"
819 || parts[3].strip_prefix("mcp-v1-").is_some_and(
820 |hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())
821 )),
822 "Approval needs a reviewed token, not a name. Run /mcp import and copy its approve or decline command"
823 );
824 Ok((parts[1], parts[2], parts[3]))
825 }
826
827 #[cfg(test)]
828 mod tests {
829 use super::*;
830 use tempfile::tempdir;
831
832 fn write_claude_json(dir: &Path, body: &str) -> PathBuf {
833 let path = dir.join(".claude.json");
834 fs::write(&path, body).unwrap();
835 path
836 }
837
838 fn with_import_context(test: impl FnOnce(&ImportContext<'_>)) {
839 let _env = crate::test_support::lock_test_env();
840 let root = tempdir().unwrap();
841 let home = root.path().join("home");
842 let workspace = root.path().join("workspace");
843 let state = root.path().join("state");
844 for path in [&home, &workspace, &state] {
845 fs::create_dir_all(path).unwrap();
846 }
847 let path = state.join("mcp.json");
848 let plugins = crate::plugins::PluginRegistry::empty(&workspace);
849 test(&ImportContext {
850 workspace: &workspace,
851 mcp_path: &path,
852 plugins: &plugins,
853 home,
854 codewhale_home: state,
855 });
856 }
857
858 #[test]
859 fn reviewed_import_rejects_changed_source_and_stale_revision() {
860 with_import_context(|context| {
861 let source =
862 write_claude_json(&context.home, r#"{"mcpServers":{"x":{"command":"echo"}}}"#);
863 let preview = preview_imports(context).unwrap();
864 let row = &preview.candidates[0];
865 fs::write(&source, r#"{"mcpServers":{"x":{"command":"changed"}}}"#).unwrap();
866 assert!(
867 apply_reviewed_import(
868 context,
869 &row.id,
870 &row.content_hash,
871 &preview.revision,
872 ImportDecision::Approve
873 )
874 .unwrap_err()
875 .to_string()
876 .contains("Source changed")
877 );
878 assert!(!context.mcp_path.exists());
879 let preview = preview_imports(context).unwrap();
880 let row = &preview.candidates[0];
881 fs::write(context.mcp_path, r#"{"servers":{}}"#).unwrap();
882 assert!(
883 apply_reviewed_import(
884 context,
885 &row.id,
886 &row.content_hash,
887 &preview.revision,
888 ImportDecision::Approve
889 )
890 .unwrap_err()
891 .is::<super::super::McpRevisionConflict>()
892 );
893 });
894 }
895
896 #[test]
897 fn reviewed_import_is_off_and_reports_history_failure_after_commit() {
898 with_import_context(|context| {
899 write_claude_json(
900 &context.home,
901 r#"{"mcpServers":{"x":{"command":"never-launch-this"}}}"#,
902 );
903 let preview = preview_imports(context).unwrap();
904 let row = &preview.candidates[0];
905 fs::write(
906 context.codewhale_home.join("mcp-import-consent.json"),
907 "invalid history",
908 )
909 .unwrap();
910 let receipt = apply_reviewed_import(
911 context,
912 &row.id,
913 &row.content_hash,
914 &preview.revision,
915 ImportDecision::Approve,
916 )
917 .unwrap();
918 assert!(receipt.imported);
919 assert!(!receipt.enabled);
920 assert!(!receipt.consent_recorded);
921 assert!(receipt.warning.is_some());
922 assert!(
923 !super::super::load_config(context.mcp_path).unwrap().servers["x"].is_enabled()
924 );
925 let next = preview_imports(context).unwrap();
926 assert!(next.candidates[0].conflict);
927 assert!(
928 apply_reviewed_import(
929 context,
930 &row.id,
931 &row.content_hash,
932 &next.revision,
933 ImportDecision::Approve
934 )
935 .is_err()
936 );
937 });
938 }
939
940 #[test]
941 fn reviewed_decline_does_not_create_config_and_preview_hides_values() {
942 with_import_context(|context| {
943 write_claude_json(
944 &context.home,
945 r#"{"mcpServers":{"x":{"url":"https://example.test/private-secret?key=secret-value","headers":{"Authorization":"header-secret"},"env":{"TOKEN":"env-secret"}}}}"#,
946 );
947 let preview = preview_imports(context).unwrap();
948 let rendered = serde_json::to_string(&preview).unwrap();
949 for secret in [
950 "private-secret",
951 "secret-value",
952 "header-secret",
953 "env-secret",
954 ] {
955 assert!(!rendered.contains(secret));
956 }
957 let row = &preview.candidates[0];
958 assert_eq!(row.destination, "https://example.test");
959 assert!(row.credential_configured);
960 assert!(parse_review_token(&row.review_token).is_ok());
961 assert!(parse_review_token("x").is_err());
962 let receipt = apply_reviewed_import(
963 context,
964 &row.id,
965 &row.content_hash,
966 &preview.revision,
967 ImportDecision::Decline,
968 )
969 .unwrap();
970 assert!(!receipt.imported);
971 assert!(receipt.consent_recorded);
972 assert_eq!(receipt.revision, preview.revision);
973 assert!(!context.mcp_path.exists());
974 });
975 }
976
977 #[test]
978 fn reviewed_import_blocks_disabled_and_untrusted_project_sources() {
979 with_import_context(|context| {
980 write_claude_json(
981 &context.home,
982 r#"{"mcpServers":{"disabled":{"command":"echo","disabled":true}}}"#,
983 );
984 fs::write(
985 context.workspace.join(".mcp.json"),
986 r#"{"mcpServers":{"project":{"command":"echo"}}}"#,
987 )
988 .unwrap();
989 let preview = preview_imports(context).unwrap();
990 assert_eq!(preview.candidates.len(), 2);
991 for row in preview.candidates {
992 assert!(row.hard_blocked);
993 assert!(
994 apply_reviewed_import(
995 context,
996 &row.id,
997 &row.content_hash,
998 &preview.revision,
999 ImportDecision::Approve
1000 )
1001 .is_err()
1002 );
1003 }
1004 assert!(!context.mcp_path.exists());
1005 });
1006 }
1007
1008 #[test]
1009 fn reviewed_discovery_rejects_oversize_and_symlink_sources() {
1010 with_import_context(|context| {
1011 let path = context.home.join(".claude.json");
1012 fs::write(
1013 &path,
1014 vec![b' '; crate::import_claude::MAX_SOURCE_BYTES as usize + 1],
1015 )
1016 .unwrap();
1017 let preview = preview_imports(context).unwrap();
1018 assert!(preview.candidates.is_empty());
1019 assert_eq!(preview.problems.len(), 1);
1020 #[cfg(unix)]
1021 {
1022 fs::remove_file(&path).unwrap();
1023 let target = context.home.join("target.json");
1024 fs::write(&target, r#"{"mcpServers":{"x":{"command":"echo"}}}"#).unwrap();
1025 std::os::unix::fs::symlink(&target, &path).unwrap();
1026 let preview = preview_imports(context).unwrap();
1027 assert!(preview.candidates.is_empty());
1028 assert_eq!(preview.problems.len(), 1);
1029 }
1030 });
1031 }
1032
1033 #[test]
1034 fn claude_code_entries_with_type_import_and_bad_siblings_are_skipped_alone() {
1035 with_import_context(|context| {
1036 write_claude_json(
1037 &context.home,
1038 r#"{"mcpServers":{
1039 "local":{"type":"stdio","command":"npx","args":["-y","pkg"]},
1040 "remote":{"type":"http","url":"https://mcp.example.test/mcp"},
1041 "legacy":{"type":"sse","url":"https://sse.example.test/sse"},
1042 "odd":{"type":"sse","command":"npx"},
1043 "extra":{"command":"echo","unknownField":"secret-value"}
1044 }}"#,
1045 );
1046 let preview = preview_imports(context).unwrap();
1047 let mut names: Vec<_> = preview.candidates.iter().map(|c| c.name.as_str()).collect();
1048 names.sort_unstable();
1049 assert_eq!(names, ["legacy", "local", "remote"]);
1050 let messages: Vec<_> = preview
1051 .problems
1052 .iter()
1053 .map(|p| p.message.as_str())
1054 .collect();
1055 assert_eq!(messages.len(), 2, "{messages:?}");
1056 assert!(messages.iter().any(|m| m.contains("'odd'")));
1057 assert!(messages.iter().any(|m| m.contains("'extra'")));
1058 assert!(
1059 !serde_json::to_string(&preview)
1060 .unwrap()
1061 .contains("secret-value")
1062 );
1063
1064 let candidates = discover_external_sources(&context.home, context.workspace, &[]);
1065 let legacy = candidates.iter().find(|c| c.name == "legacy").unwrap();
1066 assert_eq!(legacy.server.transport.as_deref(), Some("sse"));
1067 let remote = candidates.iter().find(|c| c.name == "remote").unwrap();
1068 assert_eq!(remote.server.transport, None);
1069 });
1070 }
1071
1072 #[test]
1073 fn skipped_entries_from_one_source_are_capped() {
1074 with_import_context(|context| {
1075 let entries: Vec<String> = (0..MAX_PROBLEMS_PER_SOURCE + 5)
1076 .map(|i| format!(r#""bad{i}":{{"x":1}}"#))
1077 .collect();
1078 write_claude_json(
1079 &context.home,
1080 &format!(
1081 r#"{{"mcpServers":{{{},"ok":{{"command":"echo"}}}}}}"#,
1082 entries.join(",")
1083 ),
1084 );
1085 let preview = preview_imports(context).unwrap();
1086 assert_eq!(preview.candidates.len(), 1);
1087 assert_eq!(preview.problems.len(), MAX_PROBLEMS_PER_SOURCE + 1);
1088 assert_eq!(
1089 preview.problems.last().unwrap().message,
1090 "5 more entries were skipped"
1091 );
1092 });
1093 }
1094
1095 #[test]
1096 fn unrelated_source_rewrites_keep_review_tokens_and_declines() {
1097 with_import_context(|context| {
1098 let source = write_claude_json(
1099 &context.home,
1100 r#"{"numStartups":1,"mcpServers":{"x":{"command":"echo"},"y":{"command":"echo"}}}"#,
1101 );
1102 let preview = preview_imports(context).unwrap();
1103 // Claude Code rewrites its state file on every run.
1104 fs::write(
1105 &source,
1106 r#"{"numStartups":2,"mcpServers":{"x":{"command":"echo"},"y":{"command":"echo"}}}"#,
1107 )
1108 .unwrap();
1109 let row = preview.candidates.iter().find(|c| c.name == "x").unwrap();
1110 let receipt = apply_reviewed_import(
1111 context,
1112 &row.id,
1113 &row.content_hash,
1114 &preview.revision,
1115 ImportDecision::Approve,
1116 )
1117 .unwrap();
1118 assert!(receipt.imported);
1119
1120 let candidates = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
1121 let mut store = ImportConsentStore::default();
1122 let decisions = HashMap::from([("y".to_string(), ImportDecision::Decline)]);
1123 record_decisions(&mut store, &candidates, &decisions, 1);
1124 fs::write(
1125 &source,
1126 r#"{"numStartups":3,"mcpServers":{"x":{"command":"echo"},"y":{"command":"echo"}}}"#,
1127 )
1128 .unwrap();
1129 let refreshed = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
1130 let needing: Vec<_> = candidates_needing_consent(&refreshed, &store)
1131 .into_iter()
1132 .map(|c| c.name)
1133 .collect();
1134 // `y` stays declined; `x` was never decided in this store.
1135 assert_eq!(needing, ["x"]);
1136 });
1137 }
1138
1139 #[test]
1140 fn claude_json_larger_than_the_mcp_config_bound_is_discovered() {
1141 with_import_context(|context| {
1142 let padding = "x".repeat(super::super::MAX_MCP_CONFIG_BYTES as usize * 3 / 2);
1143 write_claude_json(
1144 &context.home,
1145 &format!(r#"{{"history":"{padding}","mcpServers":{{"x":{{"command":"echo"}}}}}}"#),
1146 );
1147 let preview = preview_imports(context).unwrap();
1148 assert!(preview.problems.is_empty());
1149 assert_eq!(preview.candidates.len(), 1);
1150 assert_eq!(preview.candidates[0].name, "x");
1151 });
1152 }
1153
1154 #[test]
1155 fn consent_transaction_preserves_other_sources_and_unknown_fields() {
1156 let dir = tempdir().unwrap();
1157 let path = dir.path().join("consent.json");
1158 fs::write(&path, r#"{"entries":{},"extension":{"owner":"external"}}"#).unwrap();
1159 for name in ["first", "second"] {
1160 let source = dir.path().join(format!("{name}.json"));
1161 fs::write(
1162 &source,
1163 format!(r#"{{"mcpServers":{{"{name}":{{"command":"echo"}}}}}}"#),
1164 )
1165 .unwrap();
1166 let candidates = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
1167 let decisions = HashMap::from([(name.to_string(), ImportDecision::Approve)]);
1168 persist_decisions(&path, &candidates, &decisions, 1).unwrap();
1169 }
1170 let raw: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1171 assert_eq!(raw["extension"]["owner"], "external");
1172 assert_eq!(raw["entries"].as_object().unwrap().len(), 2);
1173 fs::write(&path, "malformed-sensitive-history").unwrap();
1174 let error = persist_decisions(&path, &[], &HashMap::new(), 2).unwrap_err();
1175 assert!(!error.to_string().contains("malformed-sensitive-history"));
1176 assert_eq!(
1177 fs::read_to_string(&path).unwrap(),
1178 "malformed-sensitive-history"
1179 );
1180 }
1181
1182 #[test]
1183 fn disabled_imported_server_never_merges() {
1184 let dir = tempdir().unwrap();
1185 let body = r#"{
1186 "mcpServers": {
1187 "ok": { "command": "npx", "args": ["-y", "good"], "enabled": true },
1188 "blocked": { "command": "npx", "args": ["-y", "bad"], "enabled": false }
1189 }
1190 }"#;
1191 write_claude_json(dir.path(), body);
1192 let candidates = discover_external_sources(dir.path(), dir.path(), &[]);
1193 assert_eq!(candidates.len(), 2);
1194 let blocked = candidates.iter().find(|c| c.name == "blocked").unwrap();
1195 assert!(blocked.hard_blocked);
1196
1197 let mut decisions = HashMap::new();
1198 decisions.insert("ok".into(), ImportDecision::Approve);
1199 decisions.insert("blocked".into(), ImportDecision::Approve);
1200 let approved = apply_approved(&candidates, &decisions);
1201 assert_eq!(approved.len(), 1);
1202 assert_eq!(approved[0].0, "ok");
1203
1204 let mut config = McpConfig::default();
1205 let inserted = merge_approved_into_config(&mut config, &approved);
1206 assert_eq!(inserted, vec!["ok".to_string()]);
1207 assert!(!config.servers.contains_key("blocked"));
1208 assert!(!config.servers["ok"].is_enabled());
1209 }
1210
1211 #[test]
1212 fn declined_consent_skips_reprompt_until_hash_changes() {
1213 let dir = tempdir().unwrap();
1214 let path = write_claude_json(
1215 dir.path(),
1216 r#"{"mcpServers":{"x":{"command":"echo","enabled":true}}}"#,
1217 );
1218 let candidates = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson);
1219 let mut store = ImportConsentStore::default();
1220 let mut decisions = HashMap::new();
1221 decisions.insert("x".into(), ImportDecision::Decline);
1222 record_decisions(&mut store, &candidates, &decisions, 1);
1223 let needing = candidates_needing_consent(&candidates, &store);
1224 assert!(needing.is_empty(), "declined should not re-prompt");
1225
1226 // Content change → new hash → re-prompt.
1227 fs::write(
1228 &path,
1229 r#"{"mcpServers":{"x":{"command":"echo","args":["changed"],"enabled":true}}}"#,
1230 )
1231 .unwrap();
1232 let refreshed = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson);
1233 let needing = candidates_needing_consent(&refreshed, &store);
1234 assert_eq!(needing.len(), 1);
1235 }
1236
1237 #[test]
1238 fn provenance_display_includes_source_and_hash() {
1239 let dir = tempdir().unwrap();
1240 write_claude_json(
1241 dir.path(),
1242 r#"{"mcpServers":{"hf":{"url":"https://example.com/mcp","enabled":true}}}"#,
1243 );
1244 let candidates = discover_external_sources(dir.path(), dir.path(), &[]);
1245 let text = format_candidates_for_display(&candidates);
1246 assert!(text.contains("provenance:"));
1247 assert!(text.contains("claude.json"));
1248 assert!(text.contains("content_hash:"));
1249 assert!(text.contains("nothing is installed until you approve"));
1250 }
1251
1252 #[test]
1253 fn project_mcp_json_and_marketplace_are_discovered() {
1254 let home = tempdir().unwrap();
1255 let workspace = tempdir().unwrap();
1256 fs::write(
1257 workspace.path().join(".mcp.json"),
1258 r#"{"mcpServers":{"team":{"command":"uvx","args":["team-mcp"]}}}"#,
1259 )
1260 .unwrap();
1261 let market = home.path().join("market.json");
1262 fs::write(
1263 &market,
1264 r#"{"servers":{"shop":{"url":"https://market.example/mcp"}}}"#,
1265 )
1266 .unwrap();
1267 let candidates = discover_external_sources(home.path(), workspace.path(), &[market]);
1268 let names: Vec<_> = candidates.iter().map(|c| c.name.as_str()).collect();
1269 assert!(names.contains(&"team"));
1270 assert!(names.contains(&"shop"));
1271 }
1272 }
1273
1273 lines RUST