handoff-mcp-server 0.24.2 → 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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +10 -2
- package/package.json +1 -1
- package/src/main.rs +10 -4
- package/src/setup.rs +198 -17
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -313,8 +313,16 @@ For non-plugin installs, run:
|
|
|
313
313
|
handoff-mcp setup
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
-
This installs Claude Code hooks into `~/.claude/settings.json`
|
|
317
|
-
|
|
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
|
+
|
|
318
326
|
Restart Claude Code after running setup.
|
|
319
327
|
|
|
320
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.
|
|
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
|
-
|
|
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
|
|
140
|
-
handoff-mcp setup --check
|
|
141
|
-
handoff-mcp setup --uninstall
|
|
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
|
|
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.
|
|
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
|
}
|