@hanna84/mcp-writing 3.8.2 → 3.9.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.9.0](https://github.com/hannasdev/mcp-writing/compare/v3.8.2...v3.9.0)
8
+
9
+ - feat(chapters): align metadata tooling with chapter ids [`#196`](https://github.com/hannasdev/mcp-writing/pull/196)
10
+
7
11
  #### [v3.8.2](https://github.com/hannasdev/mcp-writing/compare/v3.8.1...v3.8.2)
8
12
 
13
+ > 16 May 2026
14
+
9
15
  - docs(skills): align release-log path guidance [`#195`](https://github.com/hannasdev/mcp-writing/pull/195)
16
+ - Release 3.8.2 [`b5284f1`](https://github.com/hannasdev/mcp-writing/commit/b5284f144702c3532f330cfb1dbdf85f83289131)
10
17
 
11
18
  #### [v3.8.1](https://github.com/hannasdev/mcp-writing/compare/v3.8.0...v3.8.1)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.8.2",
3
+ "version": "3.9.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",
@@ -0,0 +1,61 @@
1
+ export function resolveChapterByCompatibilityKey(db, { projectId, chapterNumber, chapterId }) {
2
+ if (!projectId) return null;
3
+ if (chapterId) {
4
+ return db.prepare(`
5
+ SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
6
+ FROM chapters
7
+ WHERE project_id = ? AND chapter_id = ?
8
+ `).get(projectId, chapterId);
9
+ }
10
+ if (chapterNumber == null) return null;
11
+
12
+ const canonicalChapter = db.prepare(`
13
+ SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
14
+ FROM chapters
15
+ WHERE project_id = ? AND sort_index = ?
16
+ `).get(projectId, chapterNumber);
17
+ if (canonicalChapter) return canonicalChapter;
18
+
19
+ return db.prepare(`
20
+ SELECT chapter_id, project_id, chapter_title AS title, chapter AS sort_index, NULL AS logline, MAX(metadata_stale) AS metadata_stale
21
+ FROM scenes
22
+ WHERE project_id = ? AND chapter = ? AND chapter_id IS NOT NULL
23
+ GROUP BY chapter_id, project_id, chapter_title, chapter
24
+ ORDER BY chapter_id
25
+ LIMIT 1
26
+ `).get(projectId, chapterNumber);
27
+ }
28
+
29
+ export function resolveValidatedChapterFilter(db, { projectId, chapterNumber, chapterId }) {
30
+ if (!projectId) return { chapter: null };
31
+ if (!chapterId && chapterNumber == null) return { chapter: null };
32
+
33
+ const resolvedById = chapterId
34
+ ? resolveChapterByCompatibilityKey(db, { projectId, chapterId })
35
+ : null;
36
+ const resolvedByNumber = chapterNumber != null
37
+ ? resolveChapterByCompatibilityKey(db, { projectId, chapterNumber })
38
+ : null;
39
+
40
+ if (chapterId && chapterNumber != null) {
41
+ if (!resolvedById || !resolvedByNumber) {
42
+ return {
43
+ error: {
44
+ code: "NOT_FOUND",
45
+ message: "Chapter not found for the provided project and identifier.",
46
+ },
47
+ };
48
+ }
49
+ if (resolvedById.chapter_id !== resolvedByNumber.chapter_id) {
50
+ return {
51
+ error: {
52
+ code: "VALIDATION_ERROR",
53
+ message: "chapter_id and chapter must refer to the same canonical chapter when both are provided.",
54
+ },
55
+ };
56
+ }
57
+ return { chapter: resolvedById };
58
+ }
59
+
60
+ return { chapter: resolvedById ?? resolvedByNumber ?? null };
61
+ }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import yaml from "js-yaml";
5
5
  import { sidecarPath, syncAll } from "../sync/sync.js";
6
+ import { resolveValidatedChapterFilter } from "./chapter-resolution.js";
6
7
  import {
7
8
  slugifyEntityName,
8
9
  renderCharacterSheetTemplate,
@@ -126,6 +127,22 @@ export function resolveBatchTargetScenes(dbHandle, {
126
127
 
127
128
  const conditions = ["project_id = ?"];
128
129
  const params = [projectId];
130
+ const resolvedChapterFilter = (chapter !== undefined || chapterId !== undefined)
131
+ ? resolveValidatedChapterFilter(dbHandle, { projectId, chapterNumber: chapter, chapterId })
132
+ : { chapter: null };
133
+
134
+ if (resolvedChapterFilter.error) {
135
+ return {
136
+ ok: false,
137
+ code: resolvedChapterFilter.error.code,
138
+ message: resolvedChapterFilter.error.message,
139
+ details: {
140
+ project_id: projectId,
141
+ chapter: chapter ?? null,
142
+ chapter_id: chapterId ?? null,
143
+ },
144
+ };
145
+ }
129
146
 
130
147
  if (sceneIds?.length) {
131
148
  const placeholders = sceneIds.map(() => "?").join(",");
@@ -136,13 +153,15 @@ export function resolveBatchTargetScenes(dbHandle, {
136
153
  conditions.push("part = ?");
137
154
  params.push(part);
138
155
  }
139
- if (chapter !== undefined) {
140
- conditions.push("chapter = ?");
141
- params.push(chapter);
142
- }
143
- if (chapterId !== undefined) {
156
+ if (resolvedChapterFilter.chapter) {
157
+ conditions.push("chapter_id = ?");
158
+ params.push(resolvedChapterFilter.chapter.chapter_id);
159
+ } else if (chapterId !== undefined) {
144
160
  conditions.push("chapter_id = ?");
145
161
  params.push(chapterId);
162
+ } else if (chapter !== undefined) {
163
+ conditions.push("chapter = ?");
164
+ params.push(chapter);
146
165
  }
147
166
  if (onlyStale) {
148
167
  conditions.push("metadata_stale = 1");
@@ -16,7 +16,7 @@ import yaml from "js-yaml";
16
16
 
17
17
  const { load: parseYaml } = yaml;
18
18
 
19
- const metadataKindSchema = z.enum(["scene", "character", "place"]);
19
+ const metadataKindSchema = z.enum(["scene", "character", "place", "epigraph"]);
20
20
 
21
21
  const threadLinkSchema = z.object({
22
22
  thread_id: z.string().min(1),
@@ -71,9 +71,22 @@ const placeSchema = z.object({
71
71
  tags: z.array(z.string().min(1)).optional(),
72
72
  });
73
73
 
74
+ const epigraphSchema = z.object({
75
+ kind: z.literal("epigraph").optional(),
76
+ type: z.literal("epigraph").optional(),
77
+ epigraph_id: z.string().min(1).optional(),
78
+ scene_id: z.string().min(1).optional(),
79
+ chapter: z.number().int().positive().optional(),
80
+ chapter_id: z.string().min(1).optional(),
81
+ chapter_title: z.string().min(1).optional(),
82
+ characters: z.array(z.string().min(1)).optional(),
83
+ tags: z.array(z.string().min(1)).optional(),
84
+ });
85
+
74
86
  const sceneAllowedKeys = new Set(Object.keys(sceneSchema.shape));
75
87
  const characterAllowedKeys = new Set(Object.keys(characterSchema.shape));
76
88
  const placeAllowedKeys = new Set(Object.keys(placeSchema.shape));
89
+ const epigraphAllowedKeys = new Set(Object.keys(epigraphSchema.shape));
77
90
  const sceneLegacyKeys = new Set(["synopsis", "save_the_cat", "change"]);
78
91
 
79
92
  function uniqueItems(items = []) {
@@ -82,6 +95,7 @@ function uniqueItems(items = []) {
82
95
 
83
96
  export function detectMetadataKind(meta) {
84
97
  if (meta && typeof meta === "object") {
98
+ if (meta.kind === "epigraph" || meta.type === "epigraph" || typeof meta.epigraph_id === "string") return "epigraph";
85
99
  if (typeof meta.character_id === "string") return "character";
86
100
  if (typeof meta.place_id === "string") return "place";
87
101
  return "scene";
@@ -92,18 +106,22 @@ export function detectMetadataKind(meta) {
92
106
  function allowedKeysFor(kind) {
93
107
  if (kind === "character") return characterAllowedKeys;
94
108
  if (kind === "place") return placeAllowedKeys;
109
+ if (kind === "epigraph") return epigraphAllowedKeys;
95
110
  return sceneAllowedKeys;
96
111
  }
97
112
 
98
113
  function schemaFor(kind) {
99
114
  if (kind === "character") return characterSchema;
100
115
  if (kind === "place") return placeSchema;
116
+ if (kind === "epigraph") return epigraphSchema;
101
117
  return sceneSchema;
102
118
  }
103
119
 
104
120
  function validateUniqueArrays(meta, kind, issues) {
105
121
  const fields = kind === "scene"
106
122
  ? ["characters", "places", "tags", "scene_functions", "versions"]
123
+ : kind === "epigraph"
124
+ ? ["characters", "tags"]
107
125
  : kind === "character"
108
126
  ? ["traits", "tags"]
109
127
  : ["associated_characters", "tags"];
@@ -119,7 +137,7 @@ function validateUniqueArrays(meta, kind, issues) {
119
137
  }
120
138
  }
121
139
 
122
- function validateSceneCharacterReferenceStyle(meta, issues) {
140
+ function validateCanonicalCharacterReferenceStyle(meta, issues, { entityLabel }) {
123
141
  if (!Array.isArray(meta.characters) || meta.characters.length === 0) return;
124
142
 
125
143
  if (!meta.characters.every(value => typeof value === "string")) return;
@@ -132,7 +150,7 @@ function validateSceneCharacterReferenceStyle(meta, issues) {
132
150
  issues.push({
133
151
  level: "warning",
134
152
  code: "MIXED_CHARACTER_REFERENCE_STYLE",
135
- message: "Scene characters contain mixed canonical and non-canonical references. Prefer canonical character_id values only.",
153
+ message: `${entityLabel} characters contain mixed canonical and non-canonical references. Prefer canonical character_id values only.`,
136
154
  });
137
155
  }
138
156
 
@@ -186,7 +204,11 @@ export function validateMetadataObject(meta, { sourcePath, kindHint } = {}) {
186
204
  validateUniqueArrays(meta, kind, issues);
187
205
 
188
206
  if (kind === "scene") {
189
- validateSceneCharacterReferenceStyle(meta, issues);
207
+ validateCanonicalCharacterReferenceStyle(meta, issues, { entityLabel: "Scene" });
208
+ }
209
+
210
+ if (kind === "epigraph") {
211
+ validateCanonicalCharacterReferenceStyle(meta, issues, { entityLabel: "Epigraph" });
190
212
  }
191
213
 
192
214
  if (kind === "scene" && sourcePath) {
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
4
  import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
+ import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
6
7
  import {
7
8
  persistSceneReferenceLink,
8
9
  upsertExplicitReferenceLinkRow,
@@ -523,7 +524,8 @@ export function registerMetadataTools(s, {
523
524
  save_the_cat_beat: z.string().optional(),
524
525
  pov: z.string().optional(),
525
526
  part: z.number().int().optional(),
526
- chapter: z.number().int().optional(),
527
+ chapter: z.number().int().optional().describe("Compatibility chapter number. When it resolves to a canonical chapter, update_scene_metadata also persists the matching chapter_id."),
528
+ chapter_id: z.string().nullable().optional().describe("Canonical chapter identifier. Use list_chapters to find valid values. Pass null to clear an explicit chapter link on an unchaptered scene."),
527
529
  timeline_position: z.number().int().optional(),
528
530
  story_time: z.string().optional(),
529
531
  tags: z.array(z.string()).optional(),
@@ -542,7 +544,62 @@ export function registerMetadataTools(s, {
542
544
  }
543
545
  try {
544
546
  const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
545
- const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...fields }).meta;
547
+ const nextFields = { ...fields };
548
+
549
+ if (fields.chapter_id === null && fields.chapter !== undefined) {
550
+ return errorResponse(
551
+ "VALIDATION_ERROR",
552
+ "chapter_id cannot be null when chapter is also provided.",
553
+ {
554
+ project_id,
555
+ chapter_id: null,
556
+ chapter: fields.chapter,
557
+ }
558
+ );
559
+ }
560
+
561
+ if (fields.chapter_id === null) {
562
+ nextFields.chapter = null;
563
+ nextFields.chapter_title = null;
564
+ } else if (fields.chapter_id !== undefined || fields.chapter !== undefined) {
565
+ const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
566
+ projectId: project_id,
567
+ chapterNumber: fields.chapter,
568
+ chapterId: fields.chapter_id,
569
+ });
570
+
571
+ if (resolvedChapterFilter.error) {
572
+ return errorResponse(
573
+ resolvedChapterFilter.error.code,
574
+ resolvedChapterFilter.error.message,
575
+ {
576
+ project_id,
577
+ chapter_id: fields.chapter_id ?? null,
578
+ chapter: fields.chapter ?? null,
579
+ }
580
+ );
581
+ }
582
+
583
+ const resolvedChapter = resolvedChapterFilter.chapter;
584
+
585
+ if (!resolvedChapter) {
586
+ return errorResponse(
587
+ "NOT_FOUND",
588
+ "Chapter not found for the provided project and identifier.",
589
+ {
590
+ project_id,
591
+ chapter_id: fields.chapter_id ?? null,
592
+ chapter: fields.chapter ?? null,
593
+ }
594
+ );
595
+ }
596
+
597
+ nextFields.chapter_id = resolvedChapter.chapter_id;
598
+ nextFields.chapter = resolvedChapter.sort_index;
599
+ nextFields.chapter_title = resolvedChapter.title ?? null;
600
+ }
601
+
602
+ const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...nextFields }).meta;
546
603
  writeMeta(scene.file_path, updated);
547
604
 
548
605
  const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
4
  import { readMeta } from "../sync/sync.js";
5
5
  import { persistSceneReferenceLink, upsertExplicitReferenceLinkRow } from "./reference-link-persistence.js";
6
+ import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
6
7
 
7
8
  function accumulateSuggestionScore(scoreMap, rows, sourceLabel) {
8
9
  for (const row of rows) {
@@ -39,67 +40,6 @@ function readSceneEntityIdsFromMetadata({ scenePath, syncDir }) {
39
40
  };
40
41
  }
41
42
 
42
- function resolveChapterByCompatibilityKey(db, { projectId, chapterNumber, chapterId }) {
43
- if (!projectId) return null;
44
- if (chapterId) {
45
- return db.prepare(`
46
- SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
47
- FROM chapters
48
- WHERE project_id = ? AND chapter_id = ?
49
- `).get(projectId, chapterId);
50
- }
51
- if (chapterNumber == null) return null;
52
- const canonicalChapter = db.prepare(`
53
- SELECT chapter_id, project_id, title, sort_index, logline, metadata_stale
54
- FROM chapters
55
- WHERE project_id = ? AND sort_index = ?
56
- `).get(projectId, chapterNumber);
57
- if (canonicalChapter) return canonicalChapter;
58
-
59
- return db.prepare(`
60
- SELECT chapter_id, project_id, chapter_title AS title, chapter AS sort_index, NULL AS logline, MAX(metadata_stale) AS metadata_stale
61
- FROM scenes
62
- WHERE project_id = ? AND chapter = ? AND chapter_id IS NOT NULL
63
- GROUP BY chapter_id, project_id, chapter_title, chapter
64
- ORDER BY chapter_id
65
- LIMIT 1
66
- `).get(projectId, chapterNumber);
67
- }
68
-
69
- function resolveValidatedChapterFilter(db, { projectId, chapterNumber, chapterId }) {
70
- if (!projectId) return { chapter: null };
71
- if (!chapterId && chapterNumber == null) return { chapter: null };
72
-
73
- const resolvedById = chapterId
74
- ? resolveChapterByCompatibilityKey(db, { projectId, chapterId })
75
- : null;
76
- const resolvedByNumber = chapterNumber != null
77
- ? resolveChapterByCompatibilityKey(db, { projectId, chapterNumber })
78
- : null;
79
-
80
- if (chapterId && chapterNumber != null) {
81
- if (!resolvedById || !resolvedByNumber) {
82
- return {
83
- error: {
84
- code: "NOT_FOUND",
85
- message: "Chapter not found for the provided project and identifier.",
86
- },
87
- };
88
- }
89
- if (resolvedById.chapter_id !== resolvedByNumber.chapter_id) {
90
- return {
91
- error: {
92
- code: "VALIDATION_ERROR",
93
- message: "chapter_id and chapter must refer to the same canonical chapter when both are provided.",
94
- },
95
- };
96
- }
97
- return { chapter: resolvedById };
98
- }
99
-
100
- return { chapter: resolvedById ?? resolvedByNumber ?? null };
101
- }
102
-
103
43
  function selectApplyCandidates(enrichedCandidates, selectedDocIds, maxApply) {
104
44
  const selectedSet = selectedDocIds ? new Set(selectedDocIds) : null;
105
45
  const chosenByDocId = new Map();