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