@hanna84/mcp-writing 3.17.2 → 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,16 @@ 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
+
7
11
  #### [v3.17.2](https://github.com/hannasdev/mcp-writing/compare/v3.17.1...v3.17.2)
8
12
 
13
+ > 23 May 2026
14
+
9
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)
10
17
 
11
18
  #### [v3.17.1](https://github.com/hannasdev/mcp-writing/compare/v3.17.0...v3.17.1)
12
19
 
package/README.md CHANGED
@@ -31,7 +31,7 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
31
31
  - **Recently completed:** Docker, CI, and Deployment Workflow made Docker a supported way to build, run, smoke-test, and deploy Writing MCP.
32
32
  - **Previous milestone:** Filesystem Boundary Hardening centralized local file mutation through application-aware helpers and lint guardrails.
33
33
  - **Active development:** No active initiative is currently selected.
34
- - **Deferred backlog:** OpenClaw integration, client-agnostic setup, chapter-structure follow-up, and embeddings search.
34
+ - **Deferred backlog:** OpenClaw integration, client-agnostic setup, divisions, database backup/recovery, and embeddings search.
35
35
  - **Ideas and open questions:** tracked separately so future exploration does not distort the active roadmap.
36
36
 
37
37
  ## Who it is for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.17.2",
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",
@@ -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 {
@@ -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) {