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 CHANGED
@@ -145,7 +145,7 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "handoff-mcp"
148
- version = "0.23.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.21"
658
+ version = "1.0.22"
659
659
  source = "registry+https://github.com/rust-lang/crates.io-index"
660
- checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
660
+ checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "handoff-mcp"
3
- version = "0.23.0"
3
+ version = "0.24.0"
4
4
  edition = "2021"
5
5
  description = "MCP server that gives AI coding agents persistent memory across sessions"
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-mcp-server",
3
- "version": "0.23.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 |
@@ -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 body = arguments
65
- .get("body")
66
- .and_then(|v| v.as_str())
67
- .ok_or_else(|| anyhow::anyhow!("'body' is required"))?;
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: "Create or update a document from a full Markdown body. The 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.".to_string(),
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 body. Required." },
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-write 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
+ 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": {