@hanna84/mcp-writing 3.17.1 → 3.18.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/CHANGELOG.md CHANGED
@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v3.18.0](https://github.com/hannasdev/mcp-writing/compare/v3.17.2...v3.18.0)
8
+
9
+ - feat: complete chapter structure alignment [`#217`](https://github.com/hannasdev/mcp-writing/pull/217)
10
+
11
+ #### [v3.17.2](https://github.com/hannasdev/mcp-writing/compare/v3.17.1...v3.17.2)
12
+
13
+ > 23 May 2026
14
+
15
+ - docs: complete Docker deployment readiness [`#216`](https://github.com/hannasdev/mcp-writing/pull/216)
16
+ - Release 3.17.2 [`e2fb946`](https://github.com/hannasdev/mcp-writing/commit/e2fb946bdbae6ca4c8216edfc578d94ceda7fabf)
17
+
7
18
  #### [v3.17.1](https://github.com/hannasdev/mcp-writing/compare/v3.17.0...v3.17.1)
8
19
 
20
+ > 23 May 2026
21
+
9
22
  - docs: add database backup recovery initiative [`#215`](https://github.com/hannasdev/mcp-writing/pull/215)
23
+ - Release 3.17.1 [`a0fa16c`](https://github.com/hannasdev/mcp-writing/commit/a0fa16cac37add24dc1fbcaecd3c4301e9ee2873)
10
24
 
11
25
  #### [v3.17.0](https://github.com/hannasdev/mcp-writing/compare/v3.16.0...v3.17.0)
12
26
 
package/README.md CHANGED
@@ -28,9 +28,10 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
28
28
 
29
29
  **Current status:**
30
30
  - **Core platform complete:** Metadata-first analysis, sidecar-backed metadata maintenance, AI-assisted prose editing with confirmation + git history, review bundles, and Scrivener Direct extraction are all implemented.
31
- - **Recently completed:** Filesystem Boundary Hardening centralized local file mutation through application-aware helpers and lint guardrails.
32
- - **Active development:** Docker, CI, and Deployment Workflow is making Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
33
- - **Deferred backlog:** OpenClaw integration, client-agnostic setup, chapter-structure follow-up, and embeddings search.
31
+ - **Recently completed:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
32
+ - **Previous milestone:** Filesystem Boundary Hardening centralized local file mutation through application-aware helpers and lint guardrails.
33
+ - **Active development:** No active initiative is currently selected.
34
+ - **Deferred backlog:** OpenClaw integration, client-agnostic setup, divisions, database backup/recovery, and embeddings search.
34
35
  - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
35
36
 
36
37
  ## Who it is for
@@ -45,7 +46,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
45
46
  |---|---|
46
47
  | [docs/guides/setup.md](docs/guides/setup.md) | Prerequisites, first-time setup, Scrivener import, native sync format |
47
48
  | [mcp-writing-vscode](https://github.com/hannasdev/mcp-writing-vscode) | VS Code extension for client-native setup flows |
48
- | [docs/guides/docker.md](docs/guides/docker.md) | Docker Compose, OpenClaw integration, SSH hardening |
49
+ | [docs/guides/docker.md](docs/guides/docker.md) | Docker Compose, deployment operations, MCP gateway notes |
49
50
  | [docs/foundations/managed-structure-contract.md](docs/foundations/managed-structure-contract.md) | Design boundaries for structural mutation, generated views, import, and maintenance workflows |
50
51
  | [docs/agents/tools.md](docs/agents/tools.md) | Full tool reference — auto-generated from source |
51
52
  | [docs/agents/README.md](docs/agents/README.md) | Index of agent-focused guidance, examples, and boot files |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.17.1",
3
+ "version": "3.18.0",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "homepage": "https://hannasdev.github.io/mcp-writing/",
6
6
  "type": "module",
@@ -73,9 +73,10 @@
73
73
  "release": "release-it",
74
74
  "lint": "eslint *.js eslint-rules src/index.js src/core src/review-bundles src/runtime src/setup src/scripts src/structure src/styleguide src/sync src/tools src/workflows src/world",
75
75
  "guard:legacy-root-imports": "node src/scripts/check-legacy-root-imports.mjs",
76
+ "guard:docker-docs": "node src/scripts/check-docker-docs.mjs",
76
77
  "docs": "node src/scripts/generate-tool-docs.mjs",
77
78
  "check:docs": "npm run docs && git diff --exit-code -- docs/agents/tools.md",
78
- "check:static": "npm run lint && npm run guard:legacy-root-imports && npm run check:docs",
79
+ "check:static": "npm run lint && npm run guard:legacy-root-imports && npm run guard:docker-docs && npm run check:docs",
79
80
  "check:pr": "npm run check:static && npm test",
80
81
  "lint:metadata": "node src/scripts/lint-metadata.mjs",
81
82
  "sync:server-json-version": "node src/scripts/sync-server-json-version.mjs",
@@ -125,7 +125,7 @@ export function resolveBatchTargetScenes(dbHandle, {
125
125
  }
126
126
  }
127
127
 
128
- const conditions = ["project_id = ?"];
128
+ const conditions = ["s.project_id = ?"];
129
129
  const params = [projectId];
130
130
  const resolvedChapterFilter = (chapter !== undefined || chapterId !== undefined)
131
131
  ? resolveValidatedChapterFilter(dbHandle, { projectId, chapterNumber: chapter, chapterId })
@@ -146,26 +146,27 @@ export function resolveBatchTargetScenes(dbHandle, {
146
146
 
147
147
  if (sceneIds?.length) {
148
148
  const placeholders = sceneIds.map(() => "?").join(",");
149
- conditions.push(`scene_id IN (${placeholders})`);
149
+ conditions.push(`s.scene_id IN (${placeholders})`);
150
150
  params.push(...sceneIds);
151
151
  }
152
152
  if (part !== undefined) {
153
- conditions.push("part = ?");
153
+ conditions.push("s.part = ?");
154
154
  params.push(part);
155
155
  }
156
156
  if (resolvedChapterFilter.chapter) {
157
- conditions.push("chapter_id = ?");
157
+ conditions.push("s.chapter_id = ?");
158
158
  params.push(resolvedChapterFilter.chapter.chapter_id);
159
159
  }
160
160
  if (onlyStale) {
161
- conditions.push("metadata_stale = 1");
161
+ conditions.push("s.metadata_stale = 1");
162
162
  }
163
163
 
164
164
  const query = `
165
- SELECT scene_id, project_id, file_path
166
- FROM scenes
165
+ SELECT s.scene_id, s.project_id, s.file_path
166
+ FROM scenes s
167
+ LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
167
168
  WHERE ${conditions.join(" AND ")}
168
- ORDER BY part, chapter, timeline_position, scene_id
169
+ ORDER BY s.part, COALESCE(c.sort_index, s.chapter), s.timeline_position, s.scene_id
169
170
  `;
170
171
 
171
172
  return {
@@ -0,0 +1,77 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
6
+
7
+ const dockerfile = fs.readFileSync(path.join(ROOT, "Dockerfile"), "utf8");
8
+ const compose = fs.readFileSync(path.join(ROOT, "docker-compose.example.yml"), "utf8");
9
+ const guide = fs.readFileSync(path.join(ROOT, "docs", "guides", "docker.md"), "utf8");
10
+
11
+ const requiredDocSnippets = [
12
+ "`WRITING_SYNC_DIR`",
13
+ "`DB_PATH`",
14
+ "`HTTP_PORT`",
15
+ "`MCP_TRANSPORT`",
16
+ "`OWNERSHIP_GUARD_MODE`",
17
+ "`/sync`",
18
+ "`/data`",
19
+ "`/ssh`",
20
+ "Upgrade Checklist",
21
+ "Backup Checklist",
22
+ "Rollback",
23
+ "Supported Deployment Targets",
24
+ "OpenClaw",
25
+ ];
26
+
27
+ const requiredComposeSnippets = [
28
+ "WRITING_SYNC_DIR: /sync",
29
+ "DB_PATH: /data/writing.db",
30
+ "HTTP_PORT: \"3000\"",
31
+ "OWNERSHIP_GUARD_MODE:",
32
+ "MCP_TRANSPORT: http",
33
+ "${WRITING_SYNC_DIR_HOST:-./sync}:/sync",
34
+ "${WRITING_DATA_DIR_HOST:-./data}:/data",
35
+ "WRITING_SSH_DIR_HOST",
36
+ "GIT_SSH_COMMAND",
37
+ ];
38
+
39
+ const requiredDockerfileSnippets = [
40
+ "ENV WRITING_SYNC_DIR=/sync",
41
+ "ENV DB_PATH=/data/writing.db",
42
+ "ENV HTTP_PORT=3000",
43
+ "ENV MCP_TRANSPORT=http",
44
+ "git openssh-client",
45
+ "HEALTHCHECK",
46
+ "/healthz",
47
+ ];
48
+
49
+ const findings = [];
50
+
51
+ function requireSnippet(label, content, snippet) {
52
+ if (!content.includes(snippet)) {
53
+ findings.push(`${label} is missing ${JSON.stringify(snippet)}`);
54
+ }
55
+ }
56
+
57
+ for (const snippet of requiredDocSnippets) {
58
+ requireSnippet("docs/guides/docker.md", guide, snippet);
59
+ }
60
+
61
+ for (const snippet of requiredComposeSnippets) {
62
+ requireSnippet("docker-compose.example.yml", compose, snippet);
63
+ }
64
+
65
+ for (const snippet of requiredDockerfileSnippets) {
66
+ requireSnippet("Dockerfile", dockerfile, snippet);
67
+ }
68
+
69
+ if (findings.length > 0) {
70
+ console.error("Docker docs drift check failed:");
71
+ for (const finding of findings) {
72
+ console.error(`- ${finding}`);
73
+ }
74
+ process.exit(1);
75
+ }
76
+
77
+ console.log("Docker docs drift check passed.");
@@ -30,6 +30,8 @@ const sceneSchema = z.object({
30
30
  external_source: z.string().min(1).optional(),
31
31
  external_id: z.string().min(1).optional(),
32
32
  title: z.string().min(1).optional(),
33
+ chapter_id: z.string().min(1).nullable().optional(),
34
+ scene_role: z.enum(["prologue", "epilogue"]).nullable().optional(),
33
35
  part: z.number().int().positive().optional(),
34
36
  chapter: z.number().int().positive().optional(),
35
37
  chapter_title: z.string().min(1).optional(),
@@ -112,6 +112,7 @@ export function registerSearchTools(s, {
112
112
  s.save_the_cat_beat, s.timeline_position, s.story_time,
113
113
  s.word_count, s.metadata_stale
114
114
  FROM scenes s
115
+ LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
115
116
  `;
116
117
  const joins = [];
117
118
  const conditions = [];
@@ -139,7 +140,7 @@ export function registerSearchTools(s, {
139
140
 
140
141
  if (joins.length) query += " " + joins.join(" ");
141
142
  if (conditions.length) query += " WHERE " + conditions.join(" AND ");
142
- query += " ORDER BY s.part, s.chapter, s.timeline_position, s.scene_id";
143
+ query += " ORDER BY s.part, COALESCE(c.sort_index, s.chapter), s.timeline_position, s.scene_id";
143
144
 
144
145
  const rows = db.prepare(query).all(...params);
145
146
  if (rows.length === 0) {
@@ -424,7 +425,7 @@ export function registerSearchTools(s, {
424
425
  // ---- get_arc -------------------------------------------------------------
425
426
  s.tool(
426
427
  "get_arc",
427
- "Get every scene a character appears in, ordered by part/chapter/position. Returns scene metadata only — no prose. Use this as the primary structural entry point when the question is about a character's progression through the manuscript. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Use list_characters only when you need help finding a character_id. Response shape note: always returns a structured envelope (`results`, `total_count`, with pagination fields when paging is active).",
428
+ "Get every scene a character appears in, ordered by part, canonical chapter order, and scene position. Returns scene metadata only — no prose. Use this as the primary structural entry point when the question is about a character's progression through the manuscript. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Use list_characters only when you need help finding a character_id. Response shape note: always returns a structured envelope (`results`, `total_count`, with pagination fields when paging is active).",
428
429
  {
429
430
  character_id: z.string().describe("The character_id to trace (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
430
431
  project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
@@ -438,11 +439,12 @@ export function registerSearchTools(s, {
438
439
  s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
439
440
  FROM scenes s
440
441
  JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.project_id = s.project_id
442
+ LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
441
443
  WHERE sc.character_id = ?
442
444
  `;
443
445
  const params = [character_id];
444
446
  if (project_id) { query += ` AND s.project_id = ?`; params.push(project_id); }
445
- query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
447
+ query += ` ORDER BY s.part, COALESCE(c.sort_index, s.chapter), s.timeline_position, s.scene_id`;
446
448
 
447
449
  const rows = db.prepare(query).all(...params);
448
450
  if (rows.length === 0) {
@@ -1000,7 +1002,8 @@ export function registerSearchTools(s, {
1000
1002
  st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
1001
1003
  FROM scenes s
1002
1004
  JOIN scene_threads st ON st.scene_id = s.scene_id AND st.project_id = s.project_id AND st.thread_id = ?
1003
- ORDER BY s.part, s.chapter, s.timeline_position, s.scene_id
1005
+ LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
1006
+ ORDER BY s.part, COALESCE(c.sort_index, s.chapter), s.timeline_position, s.scene_id
1004
1007
  `).all(thread_id);
1005
1008
  const staleCount = rows.filter(r => r.metadata_stale).length;
1006
1009
  const warning = staleCount > 0 ? `${staleCount} scene(s) have stale metadata.` : undefined;
@@ -1047,11 +1050,12 @@ export function registerSearchTools(s, {
1047
1050
  FROM character_relationships r
1048
1051
  LEFT JOIN scenes s ON s.scene_id = r.scene_id
1049
1052
  AND (r.scene_id IS NULL OR s.project_id = COALESCE(?, s.project_id))
1053
+ LEFT JOIN chapters c ON c.project_id = s.project_id AND c.chapter_id = s.chapter_id
1050
1054
  WHERE r.from_character = ? AND r.to_character = ?
1051
1055
  `;
1052
1056
  const params = [project_id ?? null, from_character, to_character];
1053
1057
  if (project_id) { query += ` AND (s.project_id = ? OR r.scene_id IS NULL)`; params.push(project_id); }
1054
- query += ` ORDER BY s.part, s.chapter, s.timeline_position, s.scene_id`;
1058
+ query += ` ORDER BY s.part, COALESCE(c.sort_index, s.chapter), s.timeline_position, s.scene_id`;
1055
1059
 
1056
1060
  const rows = db.prepare(query).all(...params);
1057
1061
  if (rows.length === 0) {