handoff-mcp-server 0.24.1 → 0.24.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "handoff-mcp"
148
- version = "0.24.1"
148
+ version = "0.24.3"
149
149
  dependencies = [
150
150
  "anyhow",
151
151
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.24.1"
3
+ version = "0.24.3"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -85,6 +85,27 @@ This adds hooks that inject relevant project memories on every prompt and file
85
85
  edit. Disable anytime with `/plugin disable handoff-mcp-hooks@handoff-mcp-marketplace` —
86
86
  the MCP server and skills remain active.
87
87
 
88
+ > **Important:** The hooks require a `handoff` MCP server entry in the
89
+ > project's `.mcp.json`. If your project doesn't already have one (e.g. you
90
+ > only use the plugin), add it:
91
+ >
92
+ > ```json
93
+ > {
94
+ > "mcpServers": {
95
+ > "handoff": {
96
+ > "type": "stdio",
97
+ > "command": "handoff-mcp",
98
+ > "args": [],
99
+ > "env": {}
100
+ > }
101
+ > }
102
+ > }
103
+ > ```
104
+ >
105
+ > Without this entry, the hooks will show "not connected" errors on every
106
+ > prompt. The plugin's built-in MCP server is accessible to tools but not
107
+ > to hooks — this is a Claude Code limitation.
108
+
88
109
  > The `handoff-task-loop` and `handoff-mcp-hooks` plugins ship with
89
110
  > `defaultEnabled: false`, so they need an explicit `/plugin enable` step after
90
111
  > install. The main `handoff-mcp` plugin is `defaultEnabled: true` and skips it.
@@ -292,8 +313,16 @@ For non-plugin installs, run:
292
313
  handoff-mcp setup
293
314
  ```
294
315
 
295
- This installs Claude Code hooks into `~/.claude/settings.json` that
296
- automatically call `handoff_memory_query` on every prompt and file edit.
316
+ This installs Claude Code hooks into `~/.claude/settings.json` and adds a
317
+ `handoff` server entry to the project's `.mcp.json` (required for hooks to
318
+ connect). The command is interactive by default — use `-y` to skip prompts:
319
+
320
+ ```bash
321
+ handoff-mcp setup -y # non-interactive (auto-approve everything)
322
+ handoff-mcp setup --mcp-json # only add .mcp.json entry (skip hooks)
323
+ handoff-mcp setup --check # check if hooks and .mcp.json are configured
324
+ ```
325
+
297
326
  Restart Claude Code after running setup.
298
327
 
299
328
  You can check the current status or remove the hooks:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
4
4
  "description": "MCP server that gives AI coding agents persistent memory across sessions",
5
5
  "license": "MIT",
6
6
  "author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
package/src/main.rs CHANGED
@@ -29,7 +29,11 @@ fn main() {
29
29
  "setup" => {
30
30
  let check = args.iter().any(|a| a == "--check");
31
31
  let uninstall = args.iter().any(|a| a == "--uninstall");
32
- if let Err(e) = handoff_mcp::setup::run_setup(check, uninstall) {
32
+ let mcp_json = args.iter().any(|a| a == "--mcp-json");
33
+ let yes = args.iter().any(|a| a == "-y" || a == "--yes");
34
+ if let Err(e) =
35
+ handoff_mcp::setup::run_setup_with_opts(check, uninstall, mcp_json, yes)
36
+ {
33
37
  eprintln!("Error: {e:#}");
34
38
  std::process::exit(1);
35
39
  }
@@ -136,9 +140,11 @@ SERVER MODE:
136
140
  handoff-mcp Start the MCP server (stdio transport)
137
141
 
138
142
  SETUP:
139
- handoff-mcp setup Install memory auto-injection hooks into Claude Code
140
- handoff-mcp setup --check Check if hooks are installed
141
- handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
143
+ handoff-mcp setup Install hooks + add handoff server to .mcp.json
144
+ handoff-mcp setup --check Check if hooks and .mcp.json are configured
145
+ handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
146
+ handoff-mcp setup --mcp-json Add handoff server to .mcp.json (non-interactive)
147
+ handoff-mcp setup -y Skip all confirmation prompts
142
148
 
143
149
  OPTIONS:
144
150
  -h, --help Print this help message
package/src/setup.rs CHANGED
@@ -14,6 +14,65 @@ fn settings_path() -> Result<PathBuf> {
14
14
  Ok(Path::new(&home).join(".claude").join("settings.json"))
15
15
  }
16
16
 
17
+ fn mcp_json_path() -> PathBuf {
18
+ let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
19
+ cwd.join(".mcp.json")
20
+ }
21
+
22
+ fn build_mcp_server_entry() -> Value {
23
+ serde_json::json!({
24
+ "type": "stdio",
25
+ "command": "handoff-mcp",
26
+ "args": [],
27
+ "env": {}
28
+ })
29
+ }
30
+
31
+ fn ensure_mcp_json_entry(path: &Path) -> Result<bool> {
32
+ let mut root = if path.exists() {
33
+ let text = std::fs::read_to_string(path)
34
+ .with_context(|| format!("failed to read {}", path.display()))?;
35
+ serde_json::from_str::<Value>(&text)
36
+ .with_context(|| format!("failed to parse {}", path.display()))?
37
+ } else {
38
+ serde_json::json!({ "mcpServers": {} })
39
+ };
40
+
41
+ let servers = root
42
+ .as_object_mut()
43
+ .context(".mcp.json root is not an object")?
44
+ .entry("mcpServers")
45
+ .or_insert_with(|| Value::Object(serde_json::Map::new()))
46
+ .as_object_mut()
47
+ .context(".mcp.json 'mcpServers' is not an object")?;
48
+
49
+ if servers.contains_key(HOOK_SERVER) {
50
+ return Ok(false);
51
+ }
52
+
53
+ servers.insert(HOOK_SERVER.to_string(), build_mcp_server_entry());
54
+
55
+ let text = serde_json::to_string_pretty(&root)?;
56
+ std::fs::write(path, text + "\n")
57
+ .with_context(|| format!("failed to write {}", path.display()))?;
58
+ Ok(true)
59
+ }
60
+
61
+ fn has_mcp_json_entry(path: &Path) -> bool {
62
+ if !path.exists() {
63
+ return false;
64
+ }
65
+ let Ok(text) = std::fs::read_to_string(path) else {
66
+ return false;
67
+ };
68
+ let Ok(root) = serde_json::from_str::<Value>(&text) else {
69
+ return false;
70
+ };
71
+ root.get("mcpServers")
72
+ .and_then(|s| s.get(HOOK_SERVER))
73
+ .is_some()
74
+ }
75
+
17
76
  fn read_settings(path: &Path) -> Result<Value> {
18
77
  if path.exists() {
19
78
  let text = std::fs::read_to_string(path)
@@ -145,6 +204,15 @@ fn strip_legacy_session_start_hook(hooks_obj: &mut serde_json::Map<String, Value
145
204
  }
146
205
 
147
206
  pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
207
+ run_setup_with_opts(check_only, uninstall, false, false)
208
+ }
209
+
210
+ pub fn run_setup_with_opts(
211
+ check_only: bool,
212
+ uninstall: bool,
213
+ mcp_json: bool,
214
+ yes: bool,
215
+ ) -> Result<()> {
148
216
  anyhow::ensure!(
149
217
  !(check_only && uninstall),
150
218
  "--check and --uninstall cannot be used together"
@@ -161,7 +229,7 @@ pub fn run_setup(check_only: bool, uninstall: bool) -> Result<()> {
161
229
  return run_uninstall(&mut settings, &path);
162
230
  }
163
231
 
164
- run_install(&mut settings, &path)
232
+ run_install(&mut settings, &path, mcp_json, yes)
165
233
  }
166
234
 
167
235
  fn run_check(settings: &Value, path: &Path) -> Result<()> {
@@ -197,6 +265,17 @@ fn run_check(settings: &Value, path: &Path) -> Result<()> {
197
265
  all_ok = false;
198
266
  }
199
267
 
268
+ let mcp_path = mcp_json_path();
269
+ if has_mcp_json_entry(&mcp_path) {
270
+ println!(" .mcp.json: handoff server configured");
271
+ } else if mcp_path.exists() {
272
+ println!(" .mcp.json: handoff server NOT configured");
273
+ all_ok = false;
274
+ } else {
275
+ println!(" .mcp.json: not found (hooks need it — run `handoff-mcp setup`)");
276
+ all_ok = false;
277
+ }
278
+
200
279
  if all_ok {
201
280
  println!("\nAll hooks are configured. Memory auto-injection is active.");
202
281
  } else {
@@ -206,7 +285,19 @@ fn run_check(settings: &Value, path: &Path) -> Result<()> {
206
285
  Ok(())
207
286
  }
208
287
 
209
- fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
288
+ fn prompt_yn(question: &str) -> bool {
289
+ use std::io::{self, Write};
290
+ print!("{question} [Y/n] ");
291
+ io::stdout().flush().ok();
292
+ let mut input = String::new();
293
+ if io::stdin().read_line(&mut input).is_err() {
294
+ return true;
295
+ }
296
+ let trimmed = input.trim().to_lowercase();
297
+ trimmed.is_empty() || trimmed == "y" || trimmed == "yes"
298
+ }
299
+
300
+ fn run_install(settings: &mut Value, path: &Path, mcp_json: bool, yes: bool) -> Result<()> {
210
301
  let obj = settings
211
302
  .as_object_mut()
212
303
  .context("settings.json root is not an object")?;
@@ -247,11 +338,6 @@ fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
247
338
  installed += 1;
248
339
  }
249
340
 
250
- // Migrate away from a pre-fix install: an older `handoff-mcp setup` used
251
- // to write a synchronous SessionStart `handoff_memory_cleanup` hook,
252
- // which is the confirmed trigger for the VSCode hang under many
253
- // parallel sub-agents (wiki/100-stdio-concurrency.md). Strip it here so
254
- // that simply re-running `setup` remediates existing installs.
255
341
  let migrated = strip_legacy_session_start_hook(hooks_obj);
256
342
  if migrated {
257
343
  println!(
@@ -269,11 +355,37 @@ fn run_install(settings: &mut Value, path: &Path) -> Result<()> {
269
355
  if migrated {
270
356
  println!("Legacy SessionStart cleanup hook removed. See README for migration details.");
271
357
  }
272
- println!("\nRestart Claude Code for hooks to take effect.");
273
358
  } else {
274
- println!("\nAll hooks already installed. Nothing to do.");
359
+ println!("\nAll hooks already installed.");
275
360
  }
276
361
 
362
+ let mcp_path = mcp_json_path();
363
+ let needs_mcp = !has_mcp_json_entry(&mcp_path);
364
+
365
+ if needs_mcp {
366
+ let should_write = if mcp_json || yes {
367
+ true
368
+ } else {
369
+ println!();
370
+ prompt_yn("Add handoff server to .mcp.json? (required for hooks)")
371
+ };
372
+
373
+ if should_write {
374
+ let added = ensure_mcp_json_entry(&mcp_path)?;
375
+ if added {
376
+ println!(" .mcp.json: added handoff server entry");
377
+ }
378
+ } else {
379
+ println!(
380
+ " .mcp.json: skipped. Hooks will not work without a handoff server.\n \
381
+ Run `handoff-mcp setup --mcp-json` later, or add it manually."
382
+ );
383
+ }
384
+ } else {
385
+ println!(" .mcp.json: handoff server already configured");
386
+ }
387
+
388
+ println!("\nRestart Claude Code for changes to take effect.");
277
389
  Ok(())
278
390
  }
279
391
 
@@ -370,7 +482,7 @@ mod tests {
370
482
  let path = dir.path().join("settings.json");
371
483
 
372
484
  let mut settings = Value::Object(serde_json::Map::new());
373
- run_install(&mut settings, &path).unwrap();
485
+ run_install(&mut settings, &path, false, false).unwrap();
374
486
 
375
487
  let written: Value =
376
488
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -393,7 +505,7 @@ mod tests {
393
505
  }
394
506
  });
395
507
 
396
- run_install(&mut settings, &path).unwrap();
508
+ run_install(&mut settings, &path, false, false).unwrap();
397
509
 
398
510
  let written: Value =
399
511
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -408,10 +520,10 @@ mod tests {
408
520
  let path = dir.path().join("settings.json");
409
521
 
410
522
  let mut settings = Value::Object(serde_json::Map::new());
411
- run_install(&mut settings, &path).unwrap();
523
+ run_install(&mut settings, &path, false, false).unwrap();
412
524
 
413
525
  let mut settings2 = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
414
- run_install(&mut settings2, &path).unwrap();
526
+ run_install(&mut settings2, &path, false, false).unwrap();
415
527
 
416
528
  let written: Value =
417
529
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -429,7 +541,7 @@ mod tests {
429
541
  let path = dir.path().join("settings.json");
430
542
 
431
543
  let mut settings = Value::Object(serde_json::Map::new());
432
- run_install(&mut settings, &path).unwrap();
544
+ run_install(&mut settings, &path, false, false).unwrap();
433
545
 
434
546
  let mut settings2: Value =
435
547
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -459,7 +571,7 @@ mod tests {
459
571
  std::fs::write(&path, original).unwrap();
460
572
 
461
573
  let mut settings: Value = serde_json::from_str(original).unwrap();
462
- run_install(&mut settings, &path).unwrap();
574
+ run_install(&mut settings, &path, false, false).unwrap();
463
575
 
464
576
  let written = std::fs::read_to_string(&path).unwrap();
465
577
  let env_pos = written.find("\"env\"").unwrap();
@@ -500,7 +612,7 @@ mod tests {
500
612
  }
501
613
  });
502
614
 
503
- run_install(&mut settings, &path).unwrap();
615
+ run_install(&mut settings, &path, false, false).unwrap();
504
616
 
505
617
  let written: Value =
506
618
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -527,7 +639,7 @@ mod tests {
527
639
  }
528
640
  });
529
641
 
530
- run_install(&mut settings, &path).unwrap();
642
+ run_install(&mut settings, &path, false, false).unwrap();
531
643
 
532
644
  let written: Value =
533
645
  serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
@@ -583,4 +695,73 @@ mod tests {
583
695
  assert_eq!(user_prompt.len(), 1);
584
696
  assert!(!has_handoff_hook(&written["hooks"]["UserPromptSubmit"]));
585
697
  }
698
+
699
+ #[test]
700
+ fn ensure_mcp_json_creates_file_when_absent() {
701
+ let dir = tempfile::tempdir().unwrap();
702
+ let path = dir.path().join(".mcp.json");
703
+ assert!(!path.exists());
704
+
705
+ let added = ensure_mcp_json_entry(&path).unwrap();
706
+ assert!(added);
707
+ assert!(path.exists());
708
+
709
+ let root: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
710
+ assert!(root["mcpServers"]["handoff"]["command"]
711
+ .as_str()
712
+ .unwrap()
713
+ .contains("handoff-mcp"));
714
+ }
715
+
716
+ #[test]
717
+ fn ensure_mcp_json_adds_to_existing_file() {
718
+ let dir = tempfile::tempdir().unwrap();
719
+ let path = dir.path().join(".mcp.json");
720
+ std::fs::write(
721
+ &path,
722
+ r#"{"mcpServers":{"context7":{"type":"stdio","command":"npx"}}}"#,
723
+ )
724
+ .unwrap();
725
+
726
+ let added = ensure_mcp_json_entry(&path).unwrap();
727
+ assert!(added);
728
+
729
+ let root: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
730
+ assert!(root["mcpServers"].get("context7").is_some());
731
+ assert!(root["mcpServers"].get("handoff").is_some());
732
+ }
733
+
734
+ #[test]
735
+ fn ensure_mcp_json_skips_when_already_present() {
736
+ let dir = tempfile::tempdir().unwrap();
737
+ let path = dir.path().join(".mcp.json");
738
+ std::fs::write(
739
+ &path,
740
+ r#"{"mcpServers":{"handoff":{"type":"stdio","command":"handoff-mcp"}}}"#,
741
+ )
742
+ .unwrap();
743
+
744
+ let added = ensure_mcp_json_entry(&path).unwrap();
745
+ assert!(!added);
746
+ }
747
+
748
+ #[test]
749
+ fn has_mcp_json_entry_checks_correctly() {
750
+ let dir = tempfile::tempdir().unwrap();
751
+
752
+ let absent = dir.path().join("absent.json");
753
+ assert!(!has_mcp_json_entry(&absent));
754
+
755
+ let present = dir.path().join("present.json");
756
+ std::fs::write(&present, r#"{"mcpServers":{"handoff":{"type":"stdio"}}}"#).unwrap();
757
+ assert!(has_mcp_json_entry(&present));
758
+
759
+ let no_handoff = dir.path().join("other.json");
760
+ std::fs::write(
761
+ &no_handoff,
762
+ r#"{"mcpServers":{"context7":{"type":"stdio"}}}"#,
763
+ )
764
+ .unwrap();
765
+ assert!(!has_mcp_json_entry(&no_handoff));
766
+ }
586
767
  }