handoff-mcp-server 0.12.0 → 0.13.1
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 +43 -1
- package/Cargo.toml +4 -3
- package/README.md +208 -5
- package/package.json +1 -1
- package/scripts/cargo-env.sh +29 -0
- package/scripts/handoff-memory-hook.py +208 -0
- package/src/lib.rs +1 -0
- package/src/main.rs +47 -0
- package/src/mcp/handlers/config.rs +63 -0
- package/src/mcp/handlers/init.rs +1 -1
- package/src/mcp/handlers/memory.rs +906 -0
- package/src/mcp/handlers/mod.rs +5 -0
- package/src/mcp/tools.rs +59 -0
- package/src/setup.rs +424 -0
- package/src/storage/config.rs +52 -0
- package/src/storage/memory/injected.rs +340 -0
- package/src/storage/memory/mod.rs +235 -0
- package/src/storage/memory/model.rs +96 -0
- package/src/storage/mod.rs +2 -0
package/Cargo.lock
CHANGED
|
@@ -144,10 +144,11 @@ dependencies = [
|
|
|
144
144
|
|
|
145
145
|
[[package]]
|
|
146
146
|
name = "handoff-mcp"
|
|
147
|
-
version = "0.
|
|
147
|
+
version = "0.13.1"
|
|
148
148
|
dependencies = [
|
|
149
149
|
"anyhow",
|
|
150
150
|
"chrono",
|
|
151
|
+
"lexsim",
|
|
151
152
|
"serde",
|
|
152
153
|
"serde_json",
|
|
153
154
|
"tempfile",
|
|
@@ -242,6 +243,16 @@ version = "0.1.0"
|
|
|
242
243
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
243
244
|
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|
244
245
|
|
|
246
|
+
[[package]]
|
|
247
|
+
name = "lexsim"
|
|
248
|
+
version = "0.1.0"
|
|
249
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
250
|
+
checksum = "579b9d7ab407540bead4688eaf1675410a198d77c237a30cda55597df98926e1"
|
|
251
|
+
dependencies = [
|
|
252
|
+
"unicode-normalization",
|
|
253
|
+
"unicode-segmentation",
|
|
254
|
+
]
|
|
255
|
+
|
|
245
256
|
[[package]]
|
|
246
257
|
name = "libc"
|
|
247
258
|
version = "0.2.186"
|
|
@@ -382,6 +393,7 @@ version = "1.0.150"
|
|
|
382
393
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
383
394
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
|
384
395
|
dependencies = [
|
|
396
|
+
"indexmap",
|
|
385
397
|
"itoa",
|
|
386
398
|
"memchr",
|
|
387
399
|
"serde",
|
|
@@ -454,6 +466,21 @@ dependencies = [
|
|
|
454
466
|
"syn",
|
|
455
467
|
]
|
|
456
468
|
|
|
469
|
+
[[package]]
|
|
470
|
+
name = "tinyvec"
|
|
471
|
+
version = "1.11.0"
|
|
472
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
473
|
+
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
|
474
|
+
dependencies = [
|
|
475
|
+
"tinyvec_macros",
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
[[package]]
|
|
479
|
+
name = "tinyvec_macros"
|
|
480
|
+
version = "0.1.1"
|
|
481
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
482
|
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|
483
|
+
|
|
457
484
|
[[package]]
|
|
458
485
|
name = "toml"
|
|
459
486
|
version = "0.8.23"
|
|
@@ -501,6 +528,21 @@ version = "1.0.24"
|
|
|
501
528
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
502
529
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
|
503
530
|
|
|
531
|
+
[[package]]
|
|
532
|
+
name = "unicode-normalization"
|
|
533
|
+
version = "0.1.25"
|
|
534
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
535
|
+
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
|
536
|
+
dependencies = [
|
|
537
|
+
"tinyvec",
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
[[package]]
|
|
541
|
+
name = "unicode-segmentation"
|
|
542
|
+
version = "1.13.3"
|
|
543
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
544
|
+
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
|
545
|
+
|
|
504
546
|
[[package]]
|
|
505
547
|
name = "unicode-xid"
|
|
506
548
|
version = "0.2.6"
|
package/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "handoff-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.13.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "MCP server that gives AI coding agents persistent memory across sessions"
|
|
6
6
|
license = "MIT"
|
|
@@ -9,17 +9,18 @@ homepage = "https://github.com/alphaelements/handoff-mcp"
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = ["mcp", "ai", "claude", "handoff", "context"]
|
|
11
11
|
categories = ["command-line-utilities", "development-tools"]
|
|
12
|
-
rust-version = "1.
|
|
12
|
+
rust-version = "1.85"
|
|
13
13
|
exclude = ["lefthook.yml", "tmp/", "wiki/", "docs/", ".claude/", ".vscode/"]
|
|
14
14
|
|
|
15
15
|
[dependencies]
|
|
16
16
|
serde = { version = "1", features = ["derive"] }
|
|
17
|
-
serde_json = "1"
|
|
17
|
+
serde_json = { version = "1", features = ["preserve_order"] }
|
|
18
18
|
toml = "0.8"
|
|
19
19
|
toml_edit = "0.22"
|
|
20
20
|
chrono = { version = "0.4", features = ["serde"] }
|
|
21
21
|
anyhow = "1"
|
|
22
22
|
thiserror = "2"
|
|
23
|
+
lexsim = "0.1.0"
|
|
23
24
|
|
|
24
25
|
[dev-dependencies]
|
|
25
26
|
tempfile = "3.27.0"
|
package/README.md
CHANGED
|
@@ -33,12 +33,23 @@ At session start, the agent calls `handoff_load_context` to pick up where things
|
|
|
33
33
|
|
|
34
34
|
## Installation
|
|
35
35
|
|
|
36
|
-
###
|
|
36
|
+
### cargo (recommended if you have Rust)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cargo install handoff-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### npm
|
|
37
43
|
|
|
38
44
|
```bash
|
|
39
45
|
npm install -g handoff-mcp-server
|
|
40
46
|
```
|
|
41
47
|
|
|
48
|
+
Both install the same binary. `cargo install` fetches the crate from
|
|
49
|
+
[crates.io](https://crates.io/crates/handoff-mcp) and compiles it directly;
|
|
50
|
+
`npm install` downloads the source and runs `cargo build --release` via
|
|
51
|
+
postinstall, so either way you need a Rust toolchain.
|
|
52
|
+
|
|
42
53
|
### Build from source
|
|
43
54
|
|
|
44
55
|
```bash
|
|
@@ -73,6 +84,34 @@ The `-s user` flag registers it globally (available in all projects). Verify wit
|
|
|
73
84
|
}
|
|
74
85
|
```
|
|
75
86
|
|
|
87
|
+
### Enable automatic memory injection (optional)
|
|
88
|
+
|
|
89
|
+
If you want project memories to be automatically injected into your context
|
|
90
|
+
(instead of calling `handoff_memory_query` manually every time), run:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
handoff-mcp setup
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This installs Claude Code hooks into `~/.claude/settings.json` that
|
|
97
|
+
automatically call `handoff_memory_query` on every prompt and file edit, and run
|
|
98
|
+
`handoff_memory_cleanup` at session start. Restart Claude Code after running setup.
|
|
99
|
+
|
|
100
|
+
You can check the current status or remove the hooks:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
handoff-mcp setup --check # Show hook status
|
|
104
|
+
handoff-mcp setup --uninstall # Remove handoff hooks
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The hooks fire on every prompt and file edit, which adds a small overhead per
|
|
108
|
+
interaction. If you want to stop automatic injection, run
|
|
109
|
+
`handoff-mcp setup --uninstall` — the memory tools themselves remain available
|
|
110
|
+
for manual use, only the automatic hooks are removed.
|
|
111
|
+
|
|
112
|
+
See [Automatic injection via hooks](#automatic-injection-via-hooks) for the
|
|
113
|
+
manual configuration alternative.
|
|
114
|
+
|
|
76
115
|
## Quick Start
|
|
77
116
|
|
|
78
117
|
1. **Initialize** a project:
|
|
@@ -155,9 +194,21 @@ rename) so a concurrent reader never sees a partially-written file.
|
|
|
155
194
|
| `handoff_dashboard` | Overview of all handoff-enabled projects |
|
|
156
195
|
| `handoff_import_context` | Bulk import tasks and session data from documents |
|
|
157
196
|
| `handoff_refer` | Send a cross-project referral (bug, improvement, request) |
|
|
158
|
-
| `handoff_list_referrals` | List incoming referrals from other projects |
|
|
197
|
+
| `handoff_list_referrals` | List incoming referrals from other projects (summaries only) |
|
|
198
|
+
| `handoff_get_referral` | Fetch one incoming referral in full — details, suggested tasks, done_criteria, context |
|
|
159
199
|
| `handoff_update_referral` | Update referral status (open → acknowledged → resolved) |
|
|
160
200
|
|
|
201
|
+
### Project Memory
|
|
202
|
+
|
|
203
|
+
| Tool | Purpose |
|
|
204
|
+
|------|---------|
|
|
205
|
+
| `handoff_memory_save` | Save a durable project memory (lesson/rule/convention/gotcha); detects exact and near-duplicate memories and hands near-duplicates back for AI-driven merge |
|
|
206
|
+
| `handoff_memory_query` | Return the memories most relevant to the current prompt/file (BM25 + scope-path boost); with a `session_id`, suppresses repeats already injected this session |
|
|
207
|
+
| `handoff_memory_delete` | Delete a memory by id (full id or unique prefix) |
|
|
208
|
+
| `handoff_memory_cleanup` | Housekeeping (for SessionStart): silently merge exact duplicates, return near-duplicate/stale recommendations, gc old injection sidecars |
|
|
209
|
+
|
|
210
|
+
See [Project Memory](#project-memory-1) below for what it is and how to wire automatic injection.
|
|
211
|
+
|
|
161
212
|
### Task Data Model
|
|
162
213
|
|
|
163
214
|
Tasks are stored as a directory tree with status encoded in filenames:
|
|
@@ -223,9 +274,11 @@ name = "my-project"
|
|
|
223
274
|
description = "Project description"
|
|
224
275
|
|
|
225
276
|
[settings]
|
|
226
|
-
history_limit = 20
|
|
227
|
-
done_task_limit = 10
|
|
228
|
-
auto_git_summary = true
|
|
277
|
+
history_limit = 20 # Max closed sessions to keep
|
|
278
|
+
done_task_limit = 10 # Max completed tasks to show
|
|
279
|
+
auto_git_summary = true # Capture git state automatically
|
|
280
|
+
require_estimate_hours = true # Require estimate_hours on leaf tasks (default true)
|
|
281
|
+
ai_estimate_multiplier = 0.2 # Multiplier turning human estimates into AI-effort hours
|
|
229
282
|
|
|
230
283
|
[dashboard]
|
|
231
284
|
scan_dirs = ["~/pro/"] # Directories to scan for dashboard
|
|
@@ -263,6 +316,152 @@ mode = "compare" # plan, actual, compare
|
|
|
263
316
|
|
|
264
317
|
All configuration sections can be updated via `handoff_update_config` with dot-notation keys (e.g., `"calendar.work_hours_per_day": 7`).
|
|
265
318
|
|
|
319
|
+
### Estimates and AI effort
|
|
320
|
+
|
|
321
|
+
handoff-mcp distinguishes the **raw human-effort estimate** you record on a task
|
|
322
|
+
from the **AI-effort hours** used in scheduling and metrics:
|
|
323
|
+
|
|
324
|
+
- **`require_estimate_hours`** (default `true`) — `handoff_update_task` rejects
|
|
325
|
+
creating or updating a *leaf* task (in `todo` / `in_progress` / `review` /
|
|
326
|
+
`done`) without `schedule.estimate_hours > 0`. Parent tasks (with children) and
|
|
327
|
+
`blocked` / `skipped` tasks are exempt, and an estimate already on the task
|
|
328
|
+
satisfies the requirement. Set to `false` to opt out.
|
|
329
|
+
- **`ai_estimate_multiplier`** (default `0.2`) — the factor applied to raw
|
|
330
|
+
estimates to model how long the work takes when an AI agent does it. Always
|
|
331
|
+
record the *raw human-effort* estimate; the multiplier is applied at
|
|
332
|
+
aggregation time by `handoff_get_metrics` (`total_adjusted_estimate_hours` and
|
|
333
|
+
per-milestone `adjusted_estimate_hours`) and `handoff_get_capacity`. Raw values
|
|
334
|
+
are never overwritten.
|
|
335
|
+
|
|
336
|
+
## Project Memory
|
|
337
|
+
|
|
338
|
+
Sessions answer *"what was I doing last time?"*. **Memory** answers a longer-lived
|
|
339
|
+
question: *"what should every session in this project always know?"* — durable
|
|
340
|
+
lessons, rules, conventions, and gotchas that outlive any one session.
|
|
341
|
+
|
|
342
|
+
> **Note:** The memory tools (`handoff_memory_save`, `handoff_memory_query`, etc.) can always be
|
|
343
|
+
> called directly by the agent. For **automatic** injection — where relevant
|
|
344
|
+
> memories are surfaced on every prompt without the agent asking — you need to
|
|
345
|
+
> configure Claude Code hooks. See [Automatic injection via hooks](#automatic-injection-via-hooks).
|
|
346
|
+
|
|
347
|
+
Memories live in `.handoff/memory/` (one JSON file per memory, plus per-session
|
|
348
|
+
`injected/` sidecars). A built-in multilingual similarity engine (Japanese /
|
|
349
|
+
English, dictionary-free) ranks relevance and detects duplicates, all in-memory
|
|
350
|
+
and sub-millisecond.
|
|
351
|
+
|
|
352
|
+
### Using it directly
|
|
353
|
+
|
|
354
|
+
The agent can call the memory tools at any time:
|
|
355
|
+
|
|
356
|
+
- `handoff_memory_save` — record a memory. An exact duplicate is reported (not
|
|
357
|
+
rewritten); a near-duplicate comes back as a `conflict` with both bodies so the
|
|
358
|
+
agent can merge them (`merge_into=<id>`, `absorb_ids=[…]`) or save separately
|
|
359
|
+
with `force=true`. **handoff-mcp never merges for you** — it surfaces both
|
|
360
|
+
bodies and lets the agent decide.
|
|
361
|
+
- `handoff_memory_query` — fetch the memories most relevant to some text and/or files.
|
|
362
|
+
- `handoff_memory_delete` / `handoff_memory_cleanup` — prune and de-duplicate the store.
|
|
363
|
+
|
|
364
|
+
### Automatic injection via hooks
|
|
365
|
+
|
|
366
|
+
MCP is request/response — the server cannot push a memory into the agent's
|
|
367
|
+
context on its own. **Claude Code hooks** close that gap: they fire regardless of
|
|
368
|
+
what the agent intends, call `handoff_memory_query`, and inject the matching memories as
|
|
369
|
+
`additionalContext`. A per-session diff (keyed on the hook `session_id`) ensures
|
|
370
|
+
the same memory is **not injected twice in one session** — and an *edited* memory
|
|
371
|
+
(new content hash) is re-injected.
|
|
372
|
+
|
|
373
|
+
| Event | Calls | Effect |
|
|
374
|
+
|-------|-------|--------|
|
|
375
|
+
| `UserPromptSubmit` | `handoff_memory_query` (prompt text) | Inject memories relevant to the prompt |
|
|
376
|
+
| `PreToolUse` (`Edit\|Write\|MultiEdit`) | `handoff_memory_query` (file path) | Inject memories scoped to the file being edited |
|
|
377
|
+
| `SessionStart` | `handoff_memory_cleanup` | Merge exact duplicates, gc old sidecars |
|
|
378
|
+
|
|
379
|
+
> **Wire hooks in your *user/global* settings, not in the repo.** Hooks are a
|
|
380
|
+
> personal workflow choice; the handoff-mcp repo does not ship a `.claude/`
|
|
381
|
+
> hooks config, and you should not commit one into a shared project. Put the
|
|
382
|
+
> config in `~/.claude/settings.json` (global) or your own
|
|
383
|
+
> `.claude/settings.local.json` (git-ignored).
|
|
384
|
+
|
|
385
|
+
**Native `mcp_tool` hook (preferred).** Recent Claude Code versions can call an
|
|
386
|
+
MCP tool from a hook directly, with no wrapper script. In
|
|
387
|
+
`~/.claude/settings.json`:
|
|
388
|
+
|
|
389
|
+
```json
|
|
390
|
+
{
|
|
391
|
+
"hooks": {
|
|
392
|
+
"UserPromptSubmit": [
|
|
393
|
+
{ "hooks": [ {
|
|
394
|
+
"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query",
|
|
395
|
+
"input": { "project_dir": "${cwd}",
|
|
396
|
+
"session_id": "${session_id}", "text": "${prompt}" }
|
|
397
|
+
} ] }
|
|
398
|
+
],
|
|
399
|
+
"PreToolUse": [
|
|
400
|
+
{ "matcher": "Edit|Write|MultiEdit", "hooks": [ {
|
|
401
|
+
"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_query",
|
|
402
|
+
"input": { "project_dir": "${cwd}",
|
|
403
|
+
"session_id": "${session_id}", "tool_name": "${tool_name}",
|
|
404
|
+
"text": "${tool_input.file_path}",
|
|
405
|
+
"file_paths": ["${tool_input.file_path}"] }
|
|
406
|
+
} ] }
|
|
407
|
+
],
|
|
408
|
+
"SessionStart": [
|
|
409
|
+
{ "hooks": [ {
|
|
410
|
+
"type": "mcp_tool", "server": "handoff", "tool": "handoff_memory_cleanup",
|
|
411
|
+
"input": { "project_dir": "${cwd}" }
|
|
412
|
+
} ] }
|
|
413
|
+
]
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
(`server` must match the name you registered handoff-mcp under — `handoff` in the
|
|
419
|
+
[Setup](#setup) examples.)
|
|
420
|
+
|
|
421
|
+
**Wrapper script fallback.** If your Claude Code version doesn't support the
|
|
422
|
+
`mcp_tool` hook type, use the bundled `command` wrapper
|
|
423
|
+
[`scripts/handoff-memory-hook.py`](scripts/handoff-memory-hook.py). It reads the
|
|
424
|
+
hook JSON on stdin, calls the server over JSON-RPC, and emits
|
|
425
|
+
`additionalContext` — the memory tools return their payload as a JSON *string* so
|
|
426
|
+
both paths parse it identically. Point all three hooks at it:
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
{
|
|
430
|
+
"hooks": {
|
|
431
|
+
"UserPromptSubmit": [
|
|
432
|
+
{ "hooks": [ { "type": "command",
|
|
433
|
+
"command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
|
|
434
|
+
],
|
|
435
|
+
"PreToolUse": [
|
|
436
|
+
{ "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command",
|
|
437
|
+
"command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
|
|
438
|
+
],
|
|
439
|
+
"SessionStart": [
|
|
440
|
+
{ "hooks": [ { "type": "command",
|
|
441
|
+
"command": "/path/to/handoff-mcp/scripts/handoff-memory-hook.py" } ] }
|
|
442
|
+
]
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
The script resolves the `handoff-mcp` binary from `PATH` (override with
|
|
448
|
+
`HANDOFF_MCP_BIN`) and **fails safe**: on any error it prints nothing and exits
|
|
449
|
+
0, so a memory miss is silent and never blocks your prompt.
|
|
450
|
+
|
|
451
|
+
### Memory settings
|
|
452
|
+
|
|
453
|
+
All under `[settings]` in `.handoff/config.toml`, all with safe defaults
|
|
454
|
+
(existing projects need no change), all settable via `handoff_update_config`:
|
|
455
|
+
|
|
456
|
+
| Key | Default | Meaning |
|
|
457
|
+
|-----|---------|---------|
|
|
458
|
+
| `memory_enabled` | `true` | Master switch. When `false`, all four memory tools return a benign empty result and write nothing |
|
|
459
|
+
| `memory_dup_threshold` | `0.72` | Jaccard similarity at/above which a save is a near-duplicate conflict and cleanup groups a cluster |
|
|
460
|
+
| `memory_query_min_score` | `0.5` | BM25 relevance floor for `handoff_memory_query` results |
|
|
461
|
+
| `memory_query_limit` | `5` | Max memories returned per query |
|
|
462
|
+
| `memory_stale_days` | `60` | Days without a reference before a memory is flagged stale |
|
|
463
|
+
| `memory_injected_gc_days` | `14` | Age at which per-session injection sidecars are garbage-collected |
|
|
464
|
+
|
|
266
465
|
## MCP Resources
|
|
267
466
|
|
|
268
467
|
| URI | Description |
|
|
@@ -290,6 +489,10 @@ This project uses handoff-mcp for session continuity.
|
|
|
290
489
|
- **Decisions**: Record decisions with confidence levels as they are made,
|
|
291
490
|
not just at session end. Use `confirmed` for verified facts, `estimated`
|
|
292
491
|
for reasonable assumptions, `unverified` for unknowns.
|
|
492
|
+
- **Project memory**: Use `handoff_memory_save` to record durable lessons, rules,
|
|
493
|
+
conventions, and gotchas that every future session should know. Use
|
|
494
|
+
`handoff_memory_query` to retrieve relevant memories. Near-duplicate memories are
|
|
495
|
+
surfaced as conflicts for you to merge or force-save — never merged silently.
|
|
293
496
|
```
|
|
294
497
|
|
|
295
498
|
## Skill File (Optional)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
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>",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# shellcheck shell=sh
|
|
2
|
+
# cargo-env.sh — make cargo commands work across heterogeneous dev environments.
|
|
3
|
+
#
|
|
4
|
+
# Some environments (e.g. a Docker image where CARGO_HOME=/usr/local/cargo is
|
|
5
|
+
# owned by root) expose a CARGO_HOME that the current user cannot write to.
|
|
6
|
+
# cargo then fails to download/cache crates or fetch the advisory-db, breaking
|
|
7
|
+
# the git hooks even though nothing is wrong with the code.
|
|
8
|
+
#
|
|
9
|
+
# This script detects an unwritable CARGO_HOME and transparently falls back to
|
|
10
|
+
# the per-user $HOME/.cargo, which every supported environment can write to.
|
|
11
|
+
# When CARGO_HOME is already writable (local macOS, a synced Mac, or simply
|
|
12
|
+
# unset so cargo uses its default ~/.cargo) it leaves the environment untouched.
|
|
13
|
+
#
|
|
14
|
+
# Usage (from a hook or shell):
|
|
15
|
+
# . scripts/cargo-env.sh && cargo <args>
|
|
16
|
+
|
|
17
|
+
# Resolve the directory cargo would use right now.
|
|
18
|
+
_cargo_home="${CARGO_HOME:-$HOME/.cargo}"
|
|
19
|
+
|
|
20
|
+
# Probe writability with a temp file; fall back to $HOME/.cargo if we can't write.
|
|
21
|
+
if ! ( mkdir -p "$_cargo_home" 2>/dev/null && touch "$_cargo_home/.cargo-env-wtest" 2>/dev/null ); then
|
|
22
|
+
export CARGO_HOME="$HOME/.cargo"
|
|
23
|
+
mkdir -p "$CARGO_HOME" 2>/dev/null || true
|
|
24
|
+
else
|
|
25
|
+
rm -f "$_cargo_home/.cargo-env-wtest" 2>/dev/null || true
|
|
26
|
+
export CARGO_HOME="$_cargo_home"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
unset _cargo_home
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code hook wrapper for handoff-mcp project memory.
|
|
3
|
+
|
|
4
|
+
This is the **fallback path** for memory auto-injection. The preferred wiring is
|
|
5
|
+
a native ``mcp_tool`` hook that calls ``handoff_memory_query`` / ``handoff_memory_cleanup``
|
|
6
|
+
directly (see README "Project Memory"); this script exists for Claude Code
|
|
7
|
+
versions that lack the ``mcp_tool`` hook type, by translating a Claude Code hook
|
|
8
|
+
event into a single JSON-RPC ``tools/call`` against the handoff-mcp server and
|
|
9
|
+
emitting the result as ``hookSpecificOutput.additionalContext``.
|
|
10
|
+
|
|
11
|
+
It speaks the server's line-delimited JSON-RPC over stdio: one request object on
|
|
12
|
+
one line in, one response object on one line out. ``handoff_memory_query`` /
|
|
13
|
+
``handoff_memory_cleanup`` return their payload as a JSON *string* inside
|
|
14
|
+
``result.content[0].text`` (so both this wrapper and the native hook path parse
|
|
15
|
+
it identically), which this script re-parses.
|
|
16
|
+
|
|
17
|
+
Wiring (in ``~/.claude/settings.json`` — do NOT commit this into a repo):
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"hooks": {
|
|
21
|
+
"UserPromptSubmit": [
|
|
22
|
+
{ "hooks": [ { "type": "command",
|
|
23
|
+
"command": "handoff-mcp-memory-hook" } ] }
|
|
24
|
+
],
|
|
25
|
+
"PreToolUse": [
|
|
26
|
+
{ "matcher": "Edit|Write|MultiEdit",
|
|
27
|
+
"hooks": [ { "type": "command",
|
|
28
|
+
"command": "handoff-mcp-memory-hook" } ] }
|
|
29
|
+
],
|
|
30
|
+
"SessionStart": [
|
|
31
|
+
{ "hooks": [ { "type": "command",
|
|
32
|
+
"command": "handoff-mcp-memory-hook" } ] }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
The same script handles all three events; it picks the tool from
|
|
38
|
+
``hook_event_name`` on stdin.
|
|
39
|
+
|
|
40
|
+
Environment:
|
|
41
|
+
- ``HANDOFF_MCP_BIN`` — path to the ``handoff-mcp`` binary (default: ``handoff-mcp``
|
|
42
|
+
resolved on ``PATH``).
|
|
43
|
+
|
|
44
|
+
Design contract: this script must **never break the session**. On any error
|
|
45
|
+
(binary missing, bad JSON, timeout) it prints nothing and exits 0 — a memory
|
|
46
|
+
miss is silent, never a blocked prompt.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# `X | None` annotations are evaluated lazily so this runs on Python 3.7+
|
|
50
|
+
# (a hook host may not have 3.10). The annotations are never evaluated at runtime.
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import shutil
|
|
56
|
+
import subprocess
|
|
57
|
+
import sys
|
|
58
|
+
|
|
59
|
+
# Map a Claude Code hook event to the memory tool it drives.
|
|
60
|
+
QUERY_EVENTS = {"UserPromptSubmit", "PreToolUse"}
|
|
61
|
+
CLEANUP_EVENTS = {"SessionStart"}
|
|
62
|
+
|
|
63
|
+
# How long to wait for the server to answer one request, in seconds. The query
|
|
64
|
+
# is in-memory and sub-millisecond; this is just a safety net so a hung binary
|
|
65
|
+
# can never stall the prompt.
|
|
66
|
+
TIMEOUT_SECONDS = 5.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _server_bin() -> str | None:
|
|
70
|
+
"""Resolve the handoff-mcp binary path, or None if it can't be found."""
|
|
71
|
+
explicit = os.environ.get("HANDOFF_MCP_BIN")
|
|
72
|
+
if explicit:
|
|
73
|
+
return explicit if os.path.exists(explicit) else None
|
|
74
|
+
return shutil.which("handoff-mcp")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _call(bin_path: str, tool: str, arguments: dict) -> dict | None:
|
|
78
|
+
"""Send one ``tools/call`` line and parse the inner JSON payload.
|
|
79
|
+
|
|
80
|
+
Returns the decoded inner object (e.g. ``{"memories": [...]}`), or None on
|
|
81
|
+
any transport / decode failure. The server is stateless per line, so no
|
|
82
|
+
``initialize`` handshake is needed — a bare ``tools/call`` is answered.
|
|
83
|
+
"""
|
|
84
|
+
request = {
|
|
85
|
+
"jsonrpc": "2.0",
|
|
86
|
+
"id": 1,
|
|
87
|
+
"method": "tools/call",
|
|
88
|
+
"params": {"name": tool, "arguments": arguments},
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
proc = subprocess.run(
|
|
92
|
+
[bin_path],
|
|
93
|
+
input=json.dumps(request) + "\n",
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=TIMEOUT_SECONDS,
|
|
97
|
+
)
|
|
98
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
# The response is the first non-empty stdout line (stderr carries the
|
|
102
|
+
# startup banner and is ignored).
|
|
103
|
+
line = next((ln for ln in proc.stdout.splitlines() if ln.strip()), None)
|
|
104
|
+
if not line:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
envelope = json.loads(line)
|
|
108
|
+
text = envelope["result"]["content"][0]["text"]
|
|
109
|
+
return json.loads(text)
|
|
110
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _file_paths_from_tool_input(tool_input) -> list[str]:
|
|
115
|
+
"""Pull file paths out of a PreToolUse ``tool_input`` (Edit/Write/MultiEdit)."""
|
|
116
|
+
if not isinstance(tool_input, dict):
|
|
117
|
+
return []
|
|
118
|
+
paths = []
|
|
119
|
+
fp = tool_input.get("file_path")
|
|
120
|
+
if isinstance(fp, str) and fp:
|
|
121
|
+
paths.append(fp)
|
|
122
|
+
# MultiEdit and similar may carry an edits array; each can name a file.
|
|
123
|
+
for edit in tool_input.get("edits", []) or []:
|
|
124
|
+
if isinstance(edit, dict):
|
|
125
|
+
efp = edit.get("file_path")
|
|
126
|
+
if isinstance(efp, str) and efp:
|
|
127
|
+
paths.append(efp)
|
|
128
|
+
return paths
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _emit(event: str, context: str) -> None:
|
|
132
|
+
"""Print a Claude Code hook output object carrying additionalContext."""
|
|
133
|
+
if not context:
|
|
134
|
+
return
|
|
135
|
+
out = {
|
|
136
|
+
"hookSpecificOutput": {
|
|
137
|
+
"hookEventName": event,
|
|
138
|
+
"additionalContext": context,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
print(json.dumps(out))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _format_memories(payload: dict) -> str:
|
|
145
|
+
"""Turn a ``handoff_memory_query`` payload into an injectable context block."""
|
|
146
|
+
memories = payload.get("memories") or []
|
|
147
|
+
if not memories:
|
|
148
|
+
return ""
|
|
149
|
+
lines = ["Relevant project memories (handoff-mcp):"]
|
|
150
|
+
for m in memories:
|
|
151
|
+
text = (m.get("text") or "").strip()
|
|
152
|
+
if not text:
|
|
153
|
+
continue
|
|
154
|
+
kind = m.get("kind") or "memory"
|
|
155
|
+
lines.append(f"- [{kind}] {text}")
|
|
156
|
+
# Only a header with no bullets means nothing useful to inject.
|
|
157
|
+
return "\n".join(lines) if len(lines) > 1 else ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main() -> int:
|
|
161
|
+
raw = sys.stdin.read()
|
|
162
|
+
try:
|
|
163
|
+
hook = json.loads(raw) if raw.strip() else {}
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
return 0 # malformed hook input — stay silent, never block
|
|
166
|
+
|
|
167
|
+
event = hook.get("hook_event_name") or ""
|
|
168
|
+
bin_path = _server_bin()
|
|
169
|
+
if not bin_path:
|
|
170
|
+
return 0 # server not installed — nothing to inject
|
|
171
|
+
|
|
172
|
+
project_dir = hook.get("cwd") or os.getcwd()
|
|
173
|
+
session_id = hook.get("session_id")
|
|
174
|
+
|
|
175
|
+
if event in QUERY_EVENTS:
|
|
176
|
+
# UserPromptSubmit → match the prompt. PreToolUse → match the file(s).
|
|
177
|
+
prompt = hook.get("prompt") or ""
|
|
178
|
+
tool_name = hook.get("tool_name")
|
|
179
|
+
file_paths = _file_paths_from_tool_input(hook.get("tool_input"))
|
|
180
|
+
# Text fed to BM25: the prompt for UserPromptSubmit, else the file paths
|
|
181
|
+
# (the server also adds basenames + tool_name to the query).
|
|
182
|
+
text = prompt if prompt else " ".join(file_paths)
|
|
183
|
+
arguments = {"project_dir": project_dir, "text": text}
|
|
184
|
+
if session_id:
|
|
185
|
+
arguments["session_id"] = session_id
|
|
186
|
+
if tool_name:
|
|
187
|
+
arguments["tool_name"] = tool_name
|
|
188
|
+
if file_paths:
|
|
189
|
+
arguments["file_paths"] = file_paths
|
|
190
|
+
|
|
191
|
+
payload = _call(bin_path, "handoff_memory_query", arguments)
|
|
192
|
+
if payload:
|
|
193
|
+
_emit(event, _format_memories(payload))
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
if event in CLEANUP_EVENTS:
|
|
197
|
+
# Housekeeping only: merge exact duplicates and gc sidecars. We do not
|
|
198
|
+
# inject the cleanup recommendations as context (that is for an explicit
|
|
199
|
+
# AI-driven pass), so there is no additionalContext to emit here.
|
|
200
|
+
_call(bin_path, "handoff_memory_cleanup", {"project_dir": project_dir})
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
# Unknown event — nothing to do.
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
sys.exit(main())
|
package/src/lib.rs
CHANGED
package/src/main.rs
CHANGED
|
@@ -3,6 +3,35 @@ use std::io::{self, BufRead, Write};
|
|
|
3
3
|
use handoff_mcp::mcp::protocol::process_line;
|
|
4
4
|
|
|
5
5
|
fn main() {
|
|
6
|
+
let args: Vec<String> = std::env::args().collect();
|
|
7
|
+
|
|
8
|
+
if args.len() >= 2 {
|
|
9
|
+
match args[1].as_str() {
|
|
10
|
+
"setup" => {
|
|
11
|
+
let check = args.iter().any(|a| a == "--check");
|
|
12
|
+
let uninstall = args.iter().any(|a| a == "--uninstall");
|
|
13
|
+
if let Err(e) = handoff_mcp::setup::run_setup(check, uninstall) {
|
|
14
|
+
eprintln!("Error: {e:#}");
|
|
15
|
+
std::process::exit(1);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
"--version" | "-V" => {
|
|
20
|
+
println!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
"--help" | "-h" => {
|
|
24
|
+
print_help();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
other => {
|
|
28
|
+
eprintln!("Unknown command: {other}");
|
|
29
|
+
eprintln!("Run `handoff-mcp --help` for usage.");
|
|
30
|
+
std::process::exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
6
35
|
eprintln!("handoff-mcp v{}", env!("CARGO_PKG_VERSION"));
|
|
7
36
|
|
|
8
37
|
let stdin = io::stdin();
|
|
@@ -27,3 +56,21 @@ fn main() {
|
|
|
27
56
|
}
|
|
28
57
|
}
|
|
29
58
|
}
|
|
59
|
+
|
|
60
|
+
fn print_help() {
|
|
61
|
+
println!(
|
|
62
|
+
"handoff-mcp v{version}
|
|
63
|
+
MCP server for AI session handoff
|
|
64
|
+
|
|
65
|
+
USAGE:
|
|
66
|
+
handoff-mcp Start the MCP server (stdio transport)
|
|
67
|
+
handoff-mcp setup Install memory auto-injection hooks into Claude Code
|
|
68
|
+
handoff-mcp setup --check Check if hooks are installed
|
|
69
|
+
handoff-mcp setup --uninstall Remove handoff hooks from Claude Code
|
|
70
|
+
|
|
71
|
+
OPTIONS:
|
|
72
|
+
-h, --help Print this help message
|
|
73
|
+
-V, --version Print version",
|
|
74
|
+
version = env!("CARGO_PKG_VERSION")
|
|
75
|
+
);
|
|
76
|
+
}
|