@hanna84/mcp-writing 3.10.0 → 3.11.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.11.0](https://github.com/hannasdev/mcp-writing/compare/v3.10.0...v3.11.0)
8
+
9
+ - feat(structure): add explicit scene chapter assignment [`#203`](https://github.com/hannasdev/mcp-writing/pull/203)
10
+
7
11
  #### [v3.10.0](https://github.com/hannasdev/mcp-writing/compare/v3.9.5...v3.10.0)
8
12
 
13
+ > 18 May 2026
14
+
9
15
  - feat: add read-only structure diagnostics [`#202`](https://github.com/hannasdev/mcp-writing/pull/202)
16
+ - Release 3.10.0 [`d9b1dfb`](https://github.com/hannasdev/mcp-writing/commit/d9b1dfbce6e0391d035725f3c0671253f53ad455)
10
17
 
11
18
  #### [v3.9.5](https://github.com/hannasdev/mcp-writing/compare/v3.9.4...v3.9.5)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.10.0",
3
+ "version": "3.11.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,77 @@
1
+ import { applySceneStructurePatch } from "./structure-inference.js";
2
+
3
+ export function buildSceneChapterAssignmentPlan(syncDir, filePath, meta = {}, { chapter } = {}) {
4
+ if (chapter === undefined) {
5
+ return {
6
+ ok: false,
7
+ error: {
8
+ code: "VALIDATION_ERROR",
9
+ message: "Provide a canonical chapter or null to clear the scene chapter link.",
10
+ },
11
+ };
12
+ }
13
+
14
+ const currentStructure = applySceneStructurePatch(syncDir, filePath, meta);
15
+ const pathChapter = currentStructure.chapterStructure.chapter ?? null;
16
+ const pathChapterNumber = currentStructure.derived.chapter ?? null;
17
+
18
+ if (chapter === null) {
19
+ if (pathChapter || pathChapterNumber !== null) {
20
+ return {
21
+ ok: false,
22
+ error: {
23
+ code: "VALIDATION_ERROR",
24
+ message: "chapter_id cannot be cleared for a scene whose file path implies a chapter.",
25
+ details: {
26
+ path_chapter: pathChapter?.chapter_id ?? pathChapterNumber,
27
+ },
28
+ },
29
+ };
30
+ }
31
+
32
+ return {
33
+ ok: true,
34
+ meta: applySceneStructurePatch(syncDir, filePath, meta, { chapter: null }).meta,
35
+ assignedChapter: null,
36
+ previousChapterId: meta.chapter_id ?? null,
37
+ };
38
+ }
39
+
40
+ if (pathChapter && pathChapter.chapter_id !== chapter.chapter_id) {
41
+ return {
42
+ ok: false,
43
+ error: {
44
+ code: "VALIDATION_ERROR",
45
+ message: "Cannot assign a scene to a different chapter while its file path implies another canonical chapter.",
46
+ details: {
47
+ requested_chapter_id: chapter.chapter_id,
48
+ requested_chapter: chapter.sort_index,
49
+ path_chapter: pathChapter.chapter_id,
50
+ path_chapter_number: pathChapterNumber,
51
+ },
52
+ },
53
+ };
54
+ }
55
+
56
+ if (!pathChapter && pathChapterNumber !== null && pathChapterNumber !== chapter.sort_index) {
57
+ return {
58
+ ok: false,
59
+ error: {
60
+ code: "VALIDATION_ERROR",
61
+ message: "Cannot assign a scene to a different chapter while its file path implies another compatibility chapter.",
62
+ details: {
63
+ requested_chapter_id: chapter.chapter_id,
64
+ requested_chapter: chapter.sort_index,
65
+ path_chapter: pathChapterNumber,
66
+ },
67
+ },
68
+ };
69
+ }
70
+
71
+ return {
72
+ ok: true,
73
+ meta: applySceneStructurePatch(syncDir, filePath, meta, { chapter }).meta,
74
+ assignedChapter: chapter,
75
+ previousChapterId: meta.chapter_id ?? null,
76
+ };
77
+ }
@@ -4,6 +4,7 @@ import matter from "gray-matter";
4
4
  import { readMeta, writeMeta, indexSceneFile, applySceneStructurePatch } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
6
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
7
+ import { buildSceneChapterAssignmentPlan } from "../structure/scene-chapter-assignment.js";
7
8
  import {
8
9
  persistSceneReferenceLink,
9
10
  upsertExplicitReferenceLinkRow,
@@ -510,6 +511,92 @@ export function registerMetadataTools(s, {
510
511
  }
511
512
  );
512
513
 
514
+ // ---- assign_scene_to_chapter --------------------------------------------
515
+ s.tool(
516
+ "assign_scene_to_chapter",
517
+ "Assign a scene to a canonical chapter through the explicit structure workflow. Writes chapter_id plus compatibility chapter/chapter_title fields to the scene sidecar and refreshes the index. Pass chapter_id=null to clear an explicit chapter link on an unchaptered scene. Use list_chapters first to choose a valid canonical chapter_id.",
518
+ {
519
+ scene_id: z.string().describe("The scene_id to assign (e.g. 'sc-011-sebastian')."),
520
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
521
+ chapter_id: z.string().nullable().describe("Canonical chapter identifier. Use list_chapters to find valid values. Pass null to clear an explicit chapter link on an unchaptered scene."),
522
+ },
523
+ async ({ scene_id, project_id, chapter_id }) => {
524
+ if (!SYNC_DIR_WRITABLE) {
525
+ return errorResponse("READ_ONLY", "Cannot assign scene to chapter: sync dir is read-only.");
526
+ }
527
+
528
+ const projectIdCheck = validateProjectId(project_id);
529
+ if (!projectIdCheck.ok) {
530
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
531
+ }
532
+
533
+ const scene = db.prepare(`
534
+ SELECT scene_id, project_id, chapter_id, file_path
535
+ FROM scenes
536
+ WHERE scene_id = ? AND project_id = ?
537
+ `).get(scene_id, project_id);
538
+ if (!scene) {
539
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
540
+ }
541
+
542
+ let chapter = null;
543
+ if (chapter_id !== null) {
544
+ const resolvedChapterFilter = resolveValidatedChapterFilter(db, {
545
+ projectId: project_id,
546
+ chapterId: chapter_id,
547
+ });
548
+
549
+ if (resolvedChapterFilter.error) {
550
+ return errorResponse(
551
+ resolvedChapterFilter.error.code,
552
+ resolvedChapterFilter.error.message,
553
+ { project_id, chapter_id }
554
+ );
555
+ }
556
+
557
+ chapter = resolvedChapterFilter.chapter;
558
+ if (!chapter) {
559
+ return errorResponse("NOT_FOUND", "Chapter not found for the provided project and identifier.", {
560
+ project_id,
561
+ chapter_id,
562
+ });
563
+ }
564
+ }
565
+
566
+ try {
567
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
568
+ const plan = buildSceneChapterAssignmentPlan(SYNC_DIR, scene.file_path, meta, { chapter });
569
+ if (!plan.ok) {
570
+ return errorResponse(plan.error.code, plan.error.message, {
571
+ project_id,
572
+ scene_id,
573
+ chapter_id,
574
+ ...(plan.error.details ?? {}),
575
+ });
576
+ }
577
+
578
+ writeMeta(scene.file_path, plan.meta);
579
+
580
+ const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
581
+ indexSceneFile(db, SYNC_DIR, scene.file_path, plan.meta, prose);
582
+
583
+ return jsonResponse({
584
+ ok: true,
585
+ action: chapter === null ? "cleared" : "assigned",
586
+ scene_id,
587
+ project_id,
588
+ previous_chapter_id: plan.previousChapterId ?? scene.chapter_id ?? null,
589
+ chapter: plan.assignedChapter,
590
+ });
591
+ } catch (err) {
592
+ if (err.code === "ENOENT") {
593
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
594
+ }
595
+ return errorResponse("IO_ERROR", `Failed to assign scene '${scene_id}' to chapter: ${err.message}`);
596
+ }
597
+ }
598
+ );
599
+
513
600
  // ---- update_scene_metadata -----------------------------------------------
514
601
  s.tool(
515
602
  "update_scene_metadata",
@@ -75,6 +75,17 @@ export const WORKFLOW_CATALOGUE = [
75
75
  { tool: "suggest_scene_references", note: "Use when low parity is specifically about missing scene-to-reference relationships." },
76
76
  ],
77
77
  },
78
+ {
79
+ id: "structure_assignment",
80
+ label: "Assign a scene to a chapter",
81
+ use_when: "Use when the user wants to move an unchaptered scene into a canonical chapter, repair an explicit scene chapter link, or clear a scene's explicit chapter assignment.",
82
+ steps: [
83
+ { tool: "find_scenes", note: "Identify the target scene and confirm project_id if the user did not provide both." },
84
+ { tool: "list_chapters", note: "Choose the canonical chapter_id for the target project before assigning." },
85
+ { tool: "assign_scene_to_chapter", note: "Use this named structure workflow for chapter assignment or clearing instead of editing chapter fields through generic metadata updates." },
86
+ { tool: "diagnose_structure", note: "Run when the assignment is part of a drift repair workflow or when folder-derived structure may disagree with the requested link." },
87
+ ],
88
+ },
78
89
  {
79
90
  id: "review_preparation",
80
91
  label: "Prepare material for human review",