handoff-mcp-server 0.23.0 → 0.24.0
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 +3 -3
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/skills/handoff-docs/SKILL.md +17 -0
- package/src/mcp/handlers/docs.rs +44 -4
- package/src/mcp/tools.rs +8 -7
package/Cargo.lock
CHANGED
|
@@ -145,7 +145,7 @@ dependencies = [
|
|
|
145
145
|
|
|
146
146
|
[[package]]
|
|
147
147
|
name = "handoff-mcp"
|
|
148
|
-
version = "0.
|
|
148
|
+
version = "0.24.0"
|
|
149
149
|
dependencies = [
|
|
150
150
|
"anyhow",
|
|
151
151
|
"chrono",
|
|
@@ -655,6 +655,6 @@ dependencies = [
|
|
|
655
655
|
|
|
656
656
|
[[package]]
|
|
657
657
|
name = "zmij"
|
|
658
|
-
version = "1.0.
|
|
658
|
+
version = "1.0.22"
|
|
659
659
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
660
|
-
checksum = "
|
|
660
|
+
checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
|
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
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>",
|
|
@@ -43,6 +43,23 @@ Mark verified sections:
|
|
|
43
43
|
Check readiness:
|
|
44
44
|
- `handoff_task_checklist(task_id=..., action="view")` — combined readiness view
|
|
45
45
|
|
|
46
|
+
## Document Creation Rules
|
|
47
|
+
|
|
48
|
+
1. **One document = one complete document**
|
|
49
|
+
- Always start with a `# Title` (h1 heading)
|
|
50
|
+
- Group related content of the same category (ADR, spec, design, etc.) into a single document
|
|
51
|
+
- Example: ADR-001 through ADR-005 belong inside `# Architecture Decision Records`
|
|
52
|
+
as `## ADR-001: Redis Session` through `## ADR-005: ...` sections
|
|
53
|
+
|
|
54
|
+
2. **Use `append_body` to add sections**
|
|
55
|
+
- When adding a section to an existing document, use `append_body` instead of rewriting the whole body
|
|
56
|
+
- Example: `doc_save(doc_id="doc-...", append_body="## ADR-006: ...\n\n...")`
|
|
57
|
+
|
|
58
|
+
3. **MCP handles splitting internally**
|
|
59
|
+
- There is no need for the AI to split content into separate documents manually
|
|
60
|
+
- MCP automatically computes a section index at each h2 heading boundary
|
|
61
|
+
- Use `doc_get(format="section", seq=N)` to retrieve individual sections on demand
|
|
62
|
+
|
|
46
63
|
## The 9 Doc Tools
|
|
47
64
|
|
|
48
65
|
| Tool | Purpose |
|
package/src/mcp/handlers/docs.rs
CHANGED
|
@@ -61,12 +61,21 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
|
61
61
|
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
62
62
|
ensure_docs_dir(&handoff)?;
|
|
63
63
|
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
let body_arg = arguments.get("body").and_then(|v| v.as_str());
|
|
65
|
+
let append_body_arg = arguments.get("append_body").and_then(|v| v.as_str());
|
|
66
|
+
if body_arg.is_some() && append_body_arg.is_some() {
|
|
67
|
+
anyhow::bail!("'body' and 'append_body' are mutually exclusive");
|
|
68
|
+
}
|
|
69
|
+
if body_arg.is_none() && append_body_arg.is_none() {
|
|
70
|
+
anyhow::bail!("either 'body' or 'append_body' is required");
|
|
71
|
+
}
|
|
68
72
|
|
|
69
73
|
let doc_id = arguments.get("doc_id").and_then(|v| v.as_str());
|
|
74
|
+
if append_body_arg.is_some() && doc_id.is_none() {
|
|
75
|
+
anyhow::bail!(
|
|
76
|
+
"'append_body' requires 'doc_id' (appending to a new document is not meaningful)"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
70
79
|
let existing = match doc_id {
|
|
71
80
|
Some(id) => Some(
|
|
72
81
|
find_doc_by_id(&handoff, id)?
|
|
@@ -75,6 +84,31 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
|
75
84
|
None => None,
|
|
76
85
|
};
|
|
77
86
|
|
|
87
|
+
// `append_body`: join the appended text onto the existing document's
|
|
88
|
+
// stripped body (read_doc_body — NOT read_full_body, whose
|
|
89
|
+
// BOM/frontmatter restoration would otherwise get re-detected and
|
|
90
|
+
// double-persisted by `split()` below). No separator is inserted when
|
|
91
|
+
// the existing body is empty/missing (spec §3.1 edge case).
|
|
92
|
+
let joined_body: String;
|
|
93
|
+
let body: &str = if let Some(append_body) = append_body_arg {
|
|
94
|
+
let existing_doc = existing
|
|
95
|
+
.as_ref()
|
|
96
|
+
.expect("append_body requires doc_id, checked above, so existing is Some");
|
|
97
|
+
let existing_body = read_doc_body(&handoff, &existing_doc.slug)?.unwrap_or_default();
|
|
98
|
+
let separator = arguments
|
|
99
|
+
.get("separator")
|
|
100
|
+
.and_then(|v| v.as_str())
|
|
101
|
+
.unwrap_or("\n\n");
|
|
102
|
+
joined_body = if existing_body.is_empty() {
|
|
103
|
+
append_body.to_string()
|
|
104
|
+
} else {
|
|
105
|
+
format!("{existing_body}{separator}{append_body}")
|
|
106
|
+
};
|
|
107
|
+
&joined_body
|
|
108
|
+
} else {
|
|
109
|
+
body_arg.expect("body_arg is Some in this branch, checked above")
|
|
110
|
+
};
|
|
111
|
+
|
|
78
112
|
// slug: required for new documents, taken from the existing document on
|
|
79
113
|
// update (the `slug` argument is ignored on update — renaming a
|
|
80
114
|
// document's file-naming slug is out of scope for `doc_save`).
|
|
@@ -185,6 +219,12 @@ pub fn handle_doc_save(arguments: &Value) -> Result<String> {
|
|
|
185
219
|
// it, computed fresh on every save (no stale-fragment cleanup needed —
|
|
186
220
|
// there is nothing left on disk to clean up per section).
|
|
187
221
|
let body_after_strip: String = split_doc.fragments.iter().map(|f| f.body).collect();
|
|
222
|
+
if !body_after_strip.starts_with("# ") {
|
|
223
|
+
warnings.push(
|
|
224
|
+
"body does not start with a level-1 heading — consider adding one for readability"
|
|
225
|
+
.to_string(),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
188
228
|
write_doc_body(&handoff, &slug, &body_after_strip)?;
|
|
189
229
|
doc.sections = compute_sections(&split_doc);
|
|
190
230
|
|
package/src/mcp/tools.rs
CHANGED
|
@@ -1237,15 +1237,17 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1237
1237
|
// ---- Document management tools (P1-6a, v5 rearchitecture: wiki/130-document-management.md §3.1) ----
|
|
1238
1238
|
ToolDefinition {
|
|
1239
1239
|
name: "handoff_doc_save".to_string(),
|
|
1240
|
-
description: "
|
|
1240
|
+
description: "Save a complete document. The body MUST be a whole, human-readable Markdown document starting with a level-1 heading (e.g. `# Authentication Spec`) — group related content into ONE document (for example, all ADRs belong in a single `# Architecture Decision Records` document with each ADR as an `## ADR-001: ...` section, NOT as separate documents). MCP handles section-level indexing internally, so do not pre-split content yourself. To append a new section to an existing document (e.g. adding ADR-003) without rewriting the whole body, pass `append_body` with just the new section instead of `body` — `body` and `append_body` are mutually exclusive, and `append_body` requires an existing `doc_id` (there is nothing to append to when creating a new document). The body (or, for append_body, the resulting combined body) is stored verbatim at _doc.<slug>.md and split in-memory into a `sections` byte-offset index (no per-section files), syncing the bidirectional task<->doc link when task_ids is given. Omit doc_id to create a new document (slug is then required and must be unique); pass an existing doc_id to update it (slug is taken from the existing document — it cannot be renamed via doc_save). Returns a JSON string {doc_id,slug,title,doc_type,section_count,content_hash,warnings:[…]} — warnings lists any task_ids that could not be resolved, and includes a soft notice when the saved body does not start with a level-1 heading (the save is never rejected for this).".to_string(),
|
|
1241
1241
|
input_schema: json!({
|
|
1242
1242
|
"type": "object",
|
|
1243
1243
|
"properties": {
|
|
1244
1244
|
"project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
|
|
1245
|
-
"doc_id": { "type": "string", "description": "Existing document id to update. Omit to create a new document." },
|
|
1245
|
+
"doc_id": { "type": "string", "description": "Existing document id to update. Omit to create a new document. Required when append_body is given." },
|
|
1246
1246
|
"slug": { "type": "string", "description": "Human-readable file-naming slug ([a-z0-9-], max 60 chars), used to name _doc.<slug>.json/.md. Required when creating; ignored on update (the existing document's slug is kept)." },
|
|
1247
|
-
"title": { "type": "string", "description": "Document title. Required when creating; optional on update (defaults to the existing title)." },
|
|
1248
|
-
"body": { "type": "string", "description": "Full Markdown
|
|
1247
|
+
"title": { "type": "string", "description": "Document title. Required when creating; optional on update or append (defaults to the existing title)." },
|
|
1248
|
+
"body": { "type": "string", "description": "Full Markdown document, starting with a level-1 heading. Mutually exclusive with append_body; one of the two is required." },
|
|
1249
|
+
"append_body": { "type": "string", "description": "New section(s) to append to an existing document's body (e.g. `## ADR-003: ...`). Joined onto the existing body with `separator` before the usual split/save. Requires doc_id. Mutually exclusive with body; one of the two is required. Use the same line-ending style as the existing document." },
|
|
1250
|
+
"separator": { "type": "string", "description": "append_body only: text inserted between the existing body and append_body.", "default": "\n\n" },
|
|
1249
1251
|
"doc_type": { "type": "string", "description": "Document type.", "enum": ["spec", "design", "adr", "guide", "note"], "default": "note" },
|
|
1250
1252
|
"tags": { "type": "array", "items": { "type": "string" }, "description": "Tags for filtering/search." },
|
|
1251
1253
|
"scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this document applies to; boosts relevance in doc_list(query=...) when a file path matches." },
|
|
@@ -1254,8 +1256,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1254
1256
|
"related": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "rel": { "type": "string", "enum": ["supersedes", "references", "implements", "extends", "conflicts"] } }, "required": ["id", "rel"] }, "description": "Sibling/relative relationships to other documents." },
|
|
1255
1257
|
"split_level": { "type": "integer", "description": "ATX heading level at/above which the body is split into sections.", "default": 2 },
|
|
1256
1258
|
"auto_inject": { "type": "string", "description": "Auto-injection control.", "enum": ["auto", "full", "outline", "none"], "default": "auto" }
|
|
1257
|
-
}
|
|
1258
|
-
"required": ["body"]
|
|
1259
|
+
}
|
|
1259
1260
|
}),
|
|
1260
1261
|
},
|
|
1261
1262
|
ToolDefinition {
|
|
@@ -1417,7 +1418,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
1417
1418
|
},
|
|
1418
1419
|
ToolDefinition {
|
|
1419
1420
|
name: "handoff_doc_import".to_string(),
|
|
1420
|
-
description: "Bulk-
|
|
1421
|
+
description: "Bulk-import pre-existing Markdown files. Each file becomes ONE document in .handoff/docs/ — the file's content is stored as-is, including its h1 heading. Do NOT split a single source file into multiple documents; MCP indexes sections internally. Writes an analyzed payload (from handoff_doc_analyze, with the AI's overrides applied) as new documents. Each analyzed.auto_resolved entry must carry its file's full Markdown 'body' (doc_import writes from the payload, it does not re-read the filesystem). Each document's slug is taken from its override's 'slug' if given, else its suggested_slug, disambiguated with a numeric suffix on collision. Persists every file as a document, applies proposed_tree parent/children relationships, links task_ids to every imported document (bidirectionally), and invalidates the doc corpus cache. Returns a JSON string {imported_count,documents:[{doc_id,slug,title,section_count}],warnings:[…]}.".to_string(),
|
|
1421
1422
|
input_schema: json!({
|
|
1422
1423
|
"type": "object",
|
|
1423
1424
|
"properties": {
|