Skip to content

Commit 8693259

Browse files
fix(mcp): skip CLI mcp remove and verify after add (closes #62)
Claude/Codex install did backup, mcp remove, mcp add with no lock. A crash between remove and add dropped the paneflow entry. Skip remove, verify the file after CLI success, and fall back to the locked merge on mismatch.
1 parent a00b159 commit 8693259

2 files changed

Lines changed: 389 additions & 49 deletions

File tree

‎crates/paneflow-mcp-install/src/agents/claude_code.rs‎

Lines changed: 201 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
//!
33
//! Preferred path: shell out to `claude mcp add -s user --transport stdio
44
//! paneflow -- <bridge>` when the `claude` CLI is on PATH - it owns the
5-
//! schema and writes user-scope servers to `~/.claude.json`. Fallback when
6-
//! `claude` is absent (or the add fails): merge the entry directly into
7-
//! `~/.claude.json` under `mcpServers.paneflow`.
5+
//! schema and writes user-scope servers to `~/.claude.json`. Do **not**
6+
//! `mcp remove` first: a crash between remove and add used to drop the
7+
//! paneflow entry while the rest of the file stayed intact, and holding
8+
//! [`crate::io::ConfigLock`] across the CLI does not serialize `claude`.
9+
//! After a successful add, the on-disk file is verified; a mismatch or a
10+
//! failed add falls back to a locked merge into `~/.claude.json` under
11+
//! `mcpServers.paneflow`. Fallback also runs when `claude` is absent.
812
//!
913
//! The entry carries **no `env` block** (PRD D5): the bridge inherits
1014
//! `PANEFLOW_SOCKET_PATH` from the pane it runs in. Per 2026 verification
@@ -22,12 +26,19 @@ use crate::{io, merge};
2226
const CLI: &str = "claude";
2327
const CONTAINER: &str = "mcpServers";
2428

29+
#[cfg(test)]
30+
type CliHook = Box<dyn Fn(&[&str]) -> Result<()>>;
31+
2532
pub struct ClaudeCode {
2633
config_path: Option<PathBuf>,
2734
/// Whether shell-out to the `claude` CLI is permitted. Always true in
2835
/// production; forced false in unit tests so they never mutate the
2936
/// developer's real `~/.claude.json` via a real `claude` on PATH.
3037
allow_cli: bool,
38+
/// Test-only stand-in for `claude`. When set, install/uninstall never
39+
/// consult PATH or spawn a real process.
40+
#[cfg(test)]
41+
cli: Option<CliHook>,
3142
}
3243

3344
impl ClaudeCode {
@@ -36,6 +47,8 @@ impl ClaudeCode {
3647
Self {
3748
config_path: support::claude_config(),
3849
allow_cli: true,
50+
#[cfg(test)]
51+
cli: None,
3952
}
4053
}
4154

@@ -62,6 +75,22 @@ impl ClaudeCode {
6275
"Claude Code MCP entry must be stdio, have empty args, and no env block",
6376
)
6477
}
78+
79+
fn cli_available(&self) -> bool {
80+
#[cfg(test)]
81+
if self.cli.is_some() {
82+
return true;
83+
}
84+
self.allow_cli && support::cli_on_path(CLI)
85+
}
86+
87+
fn invoke_cli(&self, args: &[&str]) -> Result<()> {
88+
#[cfg(test)]
89+
if let Some(cli) = &self.cli {
90+
return cli(args);
91+
}
92+
support::shell_out(CLI, args)
93+
}
6594
}
6695

6796
impl Default for ClaudeCode {
@@ -96,34 +125,37 @@ impl AgentConfigWriter for ClaudeCode {
96125
}
97126
let had_prior = support::json_entry_present(path, CONTAINER)?;
98127

99-
if self.allow_cli && support::cli_on_path(CLI) {
128+
if self.cli_available() {
100129
io::backup(path)?;
101-
// A stale entry would make `add` conflict; remove it first
102-
// (best-effort - a missing entry just no-ops).
103-
if had_prior {
104-
let _ = support::shell_out(CLI, &["mcp", "remove", "paneflow"]);
105-
}
106-
match support::shell_out(
107-
CLI,
108-
&[
109-
"mcp",
110-
"add",
111-
"-s",
112-
"user",
113-
"--transport",
114-
"stdio",
115-
"paneflow",
116-
"--",
117-
&bridge_s,
118-
],
119-
) {
120-
Ok(()) => {
121-
return Ok(if had_prior {
122-
InstallOutcome::Updated
123-
} else {
124-
InstallOutcome::Installed
125-
});
126-
}
130+
// Skip `mcp remove`. A crash between remove and add used to
131+
// drop `mcpServers.paneflow` while the rest of the file stayed
132+
// intact. `add` is attempted as-is; a non-idempotent CLI (entry
133+
// already present) fails here and the locked merge repairs it.
134+
match self.invoke_cli(&[
135+
"mcp",
136+
"add",
137+
"-s",
138+
"user",
139+
"--transport",
140+
"stdio",
141+
"paneflow",
142+
"--",
143+
&bridge_s,
144+
]) {
145+
Ok(()) => match self.status(Some(bridge))? {
146+
StatusOutcome::Installed { .. } => {
147+
return Ok(if had_prior {
148+
InstallOutcome::Updated
149+
} else {
150+
InstallOutcome::Installed
151+
});
152+
}
153+
_ => {
154+
log::warn!(
155+
"paneflow mcp: `claude mcp add` exited 0 but ~/.claude.json does not match the managed entry; falling back to direct merge"
156+
);
157+
}
158+
},
127159
Err(e) => {
128160
log::warn!(
129161
"paneflow mcp: `claude mcp add` failed ({e:#}); falling back to direct ~/.claude.json merge"
@@ -148,9 +180,9 @@ impl AgentConfigWriter for ClaudeCode {
148180
if !support::json_entry_present(path, CONTAINER)? {
149181
return Ok(UninstallOutcome::NothingToRemove);
150182
}
151-
if self.allow_cli && support::cli_on_path(CLI) {
183+
if self.cli_available() {
152184
io::backup(path)?;
153-
if let Ok(()) = support::shell_out(CLI, &["mcp", "remove", "paneflow"]) {
185+
if let Ok(()) = self.invoke_cli(&["mcp", "remove", "paneflow"]) {
154186
return Ok(UninstallOutcome::Removed);
155187
}
156188
}
@@ -165,14 +197,36 @@ impl AgentConfigWriter for ClaudeCode {
165197
#[cfg(test)]
166198
mod tests {
167199
use super::*;
200+
use std::cell::RefCell;
201+
use std::rc::Rc;
168202

169203
fn test_writer(path: PathBuf) -> ClaudeCode {
170204
ClaudeCode {
171205
config_path: Some(path),
172206
allow_cli: false, // never shell out to a real `claude` in tests
207+
cli: None,
173208
}
174209
}
175210

211+
fn record_args(calls: &Rc<RefCell<Vec<Vec<String>>>>, args: &[&str]) {
212+
calls
213+
.borrow_mut()
214+
.push(args.iter().map(|s| (*s).to_string()).collect());
215+
}
216+
217+
fn assert_add_without_remove(calls: &[Vec<String>]) {
218+
assert!(
219+
calls.iter().all(|args| !args.iter().any(|a| a == "remove")),
220+
"install must not call mcp remove: {calls:?}"
221+
);
222+
assert!(
223+
calls
224+
.iter()
225+
.any(|args| args.windows(2).any(|w| w == ["mcp", "add"])),
226+
"expected mcp add: {calls:?}"
227+
);
228+
}
229+
176230
#[test]
177231
fn install_writes_stdio_entry_without_env() {
178232
let dir = tempfile::TempDir::new().unwrap();
@@ -283,6 +337,121 @@ mod tests {
283337
assert_eq!(w.uninstall().unwrap(), UninstallOutcome::NothingToRemove);
284338
}
285339

340+
#[test]
341+
fn install_cli_add_skips_remove_and_verifies_file() {
342+
let dir = tempfile::TempDir::new().unwrap();
343+
let p = dir.path().join(".claude.json");
344+
let dest = p.clone();
345+
let calls = Rc::new(RefCell::new(Vec::new()));
346+
let calls_h = Rc::clone(&calls);
347+
let w = ClaudeCode {
348+
config_path: Some(p.clone()),
349+
allow_cli: true,
350+
cli: Some(Box::new(move |args| {
351+
record_args(&calls_h, args);
352+
let v = json!({
353+
"mcpServers": {
354+
"paneflow": {
355+
"type": "stdio",
356+
"command": "/data/paneflow-mcp",
357+
"args": []
358+
}
359+
}
360+
});
361+
std::fs::write(&dest, serde_json::to_vec(&v).unwrap()).unwrap();
362+
Ok(())
363+
})),
364+
};
365+
366+
assert_eq!(
367+
w.install(Path::new("/data/paneflow-mcp")).unwrap(),
368+
InstallOutcome::Installed
369+
);
370+
assert_add_without_remove(&calls.borrow());
371+
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
372+
assert_eq!(
373+
v["mcpServers"]["paneflow"]["command"],
374+
json!("/data/paneflow-mcp")
375+
);
376+
}
377+
378+
#[test]
379+
fn install_cli_success_falls_back_when_file_does_not_match() {
380+
// CLI exited 0 but did not write our watched file (e.g. it edited
381+
// `$CLAUDE_CONFIG_DIR/.claude.json` while we track `$HOME`).
382+
let dir = tempfile::TempDir::new().unwrap();
383+
let p = dir.path().join(".claude.json");
384+
let calls = Rc::new(RefCell::new(Vec::new()));
385+
let calls_h = Rc::clone(&calls);
386+
let w = ClaudeCode {
387+
config_path: Some(p.clone()),
388+
allow_cli: true,
389+
cli: Some(Box::new(move |args| {
390+
record_args(&calls_h, args);
391+
Ok(())
392+
})),
393+
};
394+
395+
assert_eq!(
396+
w.install(Path::new("/data/paneflow-mcp")).unwrap(),
397+
InstallOutcome::Installed
398+
);
399+
assert_add_without_remove(&calls.borrow());
400+
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
401+
assert_eq!(
402+
v["mcpServers"]["paneflow"]["command"],
403+
json!("/data/paneflow-mcp")
404+
);
405+
assert_eq!(v["mcpServers"]["paneflow"]["type"], json!("stdio"));
406+
}
407+
408+
#[test]
409+
fn install_cli_failure_falls_back_without_remove() {
410+
// Non-idempotent `mcp add` (stale entry already present) must not
411+
// be preceded by `mcp remove`; the locked merge updates in place.
412+
let dir = tempfile::TempDir::new().unwrap();
413+
let p = dir.path().join(".claude.json");
414+
std::fs::write(
415+
&p,
416+
serde_json::to_vec(&json!({
417+
"numStartups": 42,
418+
"mcpServers": {
419+
"github": { "command": "gh-mcp" },
420+
"paneflow": {
421+
"type": "stdio",
422+
"command": "/old/paneflow-mcp",
423+
"args": []
424+
}
425+
}
426+
}))
427+
.unwrap(),
428+
)
429+
.unwrap();
430+
let calls = Rc::new(RefCell::new(Vec::new()));
431+
let calls_h = Rc::clone(&calls);
432+
let w = ClaudeCode {
433+
config_path: Some(p.clone()),
434+
allow_cli: true,
435+
cli: Some(Box::new(move |args| {
436+
record_args(&calls_h, args);
437+
Err(anyhow!("mcp add: already exists"))
438+
})),
439+
};
440+
441+
assert_eq!(
442+
w.install(Path::new("/data/paneflow-mcp")).unwrap(),
443+
InstallOutcome::Updated
444+
);
445+
assert_add_without_remove(&calls.borrow());
446+
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
447+
assert_eq!(v["numStartups"], json!(42));
448+
assert_eq!(v["mcpServers"]["github"]["command"], json!("gh-mcp"));
449+
assert_eq!(
450+
v["mcpServers"]["paneflow"]["command"],
451+
json!("/data/paneflow-mcp")
452+
);
453+
}
454+
286455
#[test]
287456
fn uninstall_then_status_roundtrip() {
288457
let dir = tempfile::TempDir::new().unwrap();

0 commit comments

Comments
 (0)