@hanna84/mcp-writing 3.13.1 → 3.14.1

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.14.1](https://github.com/hannasdev/mcp-writing/compare/v3.14.0...v3.14.1)
8
+
9
+ - ci: optimize validation workflow [`#208`](https://github.com/hannasdev/mcp-writing/pull/208)
10
+
11
+ #### [v3.14.0](https://github.com/hannasdev/mcp-writing/compare/v3.13.1...v3.14.0)
12
+
13
+ > 19 May 2026
14
+
15
+ - feat: add deterministic structure exports [`#207`](https://github.com/hannasdev/mcp-writing/pull/207)
16
+ - Release 3.14.0 [`94fc46b`](https://github.com/hannasdev/mcp-writing/commit/94fc46bc98996688479a2bee9fc73415f69c3a86)
17
+
7
18
  #### [v3.13.1](https://github.com/hannasdev/mcp-writing/compare/v3.13.0...v3.13.1)
8
19
 
20
+ > 19 May 2026
21
+
9
22
  - docs(product): mark M7 complete [`#206`](https://github.com/hannasdev/mcp-writing/pull/206)
23
+ - Release 3.13.1 [`6f984b9`](https://github.com/hannasdev/mcp-writing/commit/6f984b9d53ad8187749b4038338e380704e398c6)
10
24
 
11
25
  #### [v3.13.0](https://github.com/hannasdev/mcp-writing/compare/v3.12.0...v3.13.0)
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.13.1",
3
+ "version": "3.14.1",
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",
@@ -74,6 +74,9 @@
74
74
  "lint": "eslint *.js 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
76
  "docs": "node src/scripts/generate-tool-docs.mjs",
77
+ "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:pr": "npm run check:static && npm test",
77
80
  "lint:metadata": "node src/scripts/lint-metadata.mjs",
78
81
  "sync:server-json-version": "node src/scripts/sync-server-json-version.mjs",
79
82
  "test:unit": "node --experimental-sqlite --test src/test/unit/*.test.mjs",
@@ -0,0 +1,228 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export const STRUCTURE_EXPORT_SCHEMA_VERSION = 1;
6
+
7
+ function normalizePathForExport(syncDir, filePath) {
8
+ if (!filePath) return null;
9
+ const syncRoot = path.resolve(syncDir);
10
+ const resolvedPath = path.isAbsolute(filePath)
11
+ ? path.resolve(filePath)
12
+ : path.resolve(syncRoot, filePath);
13
+ const normalized = path.relative(syncRoot, resolvedPath);
14
+ if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
15
+ throw new Error(`Cannot export path outside sync_dir: ${filePath}`);
16
+ }
17
+ return normalized.split(path.sep).join("/");
18
+ }
19
+
20
+ function stableStringify(value, indent = 2) {
21
+ const seen = new WeakSet();
22
+ function normalize(input) {
23
+ if (input === null || typeof input !== "object") return input;
24
+ if (seen.has(input)) {
25
+ throw new TypeError("Cannot stable-stringify circular structure.");
26
+ }
27
+ seen.add(input);
28
+ if (Array.isArray(input)) {
29
+ const array = input.map(normalize);
30
+ seen.delete(input);
31
+ return array;
32
+ }
33
+ const object = {};
34
+ for (const key of Object.keys(input).sort()) {
35
+ object[key] = normalize(input[key]);
36
+ }
37
+ seen.delete(input);
38
+ return object;
39
+ }
40
+
41
+ return JSON.stringify(normalize(value), null, indent);
42
+ }
43
+
44
+ function sha256(value) {
45
+ return crypto.createHash("sha256").update(value).digest("hex");
46
+ }
47
+
48
+ export function defaultStructureExportFileName(projectId) {
49
+ const slug = String(projectId ?? "project")
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, "-")
52
+ .replace(/^-+|-+$/g, "") || "project";
53
+ return `${slug}.structure.json`;
54
+ }
55
+
56
+ export function buildStructureExport(db, { projectId, syncDir }) {
57
+ const project = db.prepare(`
58
+ SELECT project_id, universe_id, name
59
+ FROM projects
60
+ WHERE project_id = ?
61
+ `).get(projectId);
62
+ if (!project) {
63
+ return {
64
+ ok: false,
65
+ error: {
66
+ code: "NOT_FOUND",
67
+ message: `Project '${projectId}' not found.`,
68
+ details: { project_id: projectId },
69
+ },
70
+ };
71
+ }
72
+
73
+ const chapters = db.prepare(`
74
+ SELECT chapter_id, title, sort_index, logline, source_path, source_checksum, metadata_stale, updated_at
75
+ FROM chapters
76
+ WHERE project_id = ?
77
+ ORDER BY sort_index, chapter_id
78
+ `).all(projectId).map(row => ({
79
+ chapter_id: row.chapter_id,
80
+ title: row.title,
81
+ sort_index: row.sort_index,
82
+ logline: row.logline ?? null,
83
+ source_path: normalizePathForExport(syncDir, row.source_path),
84
+ source_checksum: row.source_checksum ?? null,
85
+ metadata_stale: row.metadata_stale,
86
+ updated_at: row.updated_at,
87
+ }));
88
+
89
+ const scenes = db.prepare(`
90
+ SELECT
91
+ s.scene_id,
92
+ s.title,
93
+ s.chapter_id,
94
+ s.scene_role,
95
+ s.part,
96
+ s.chapter,
97
+ s.chapter_title,
98
+ s.timeline_position,
99
+ s.file_path,
100
+ s.prose_checksum,
101
+ s.metadata_stale,
102
+ s.updated_at
103
+ FROM scenes s
104
+ LEFT JOIN chapters c
105
+ ON c.project_id = s.project_id
106
+ AND c.chapter_id = s.chapter_id
107
+ WHERE s.project_id = ?
108
+ ORDER BY
109
+ CASE WHEN c.sort_index IS NULL THEN 2147483647 ELSE c.sort_index END,
110
+ CASE WHEN s.timeline_position IS NULL THEN 1 ELSE 0 END,
111
+ s.timeline_position,
112
+ s.scene_id
113
+ `).all(projectId).map(row => ({
114
+ scene_id: row.scene_id,
115
+ title: row.title ?? null,
116
+ chapter_id: row.chapter_id ?? null,
117
+ scene_role: row.scene_role ?? null,
118
+ part: row.part ?? null,
119
+ compatibility_chapter: row.chapter ?? null,
120
+ compatibility_chapter_title: row.chapter_title ?? null,
121
+ timeline_position: row.timeline_position ?? null,
122
+ file_path: normalizePathForExport(syncDir, row.file_path),
123
+ prose_checksum: row.prose_checksum ?? null,
124
+ metadata_stale: row.metadata_stale,
125
+ updated_at: row.updated_at,
126
+ }));
127
+
128
+ const epigraphs = db.prepare(`
129
+ SELECT
130
+ e.epigraph_id,
131
+ e.chapter_id,
132
+ e.file_path,
133
+ e.prose_checksum,
134
+ e.metadata_stale,
135
+ e.updated_at
136
+ FROM epigraphs e
137
+ LEFT JOIN chapters c
138
+ ON c.project_id = e.project_id
139
+ AND c.chapter_id = e.chapter_id
140
+ WHERE e.project_id = ?
141
+ ORDER BY
142
+ CASE WHEN c.sort_index IS NULL THEN 2147483647 ELSE c.sort_index END,
143
+ e.epigraph_id
144
+ `).all(projectId).map(row => ({
145
+ epigraph_id: row.epigraph_id,
146
+ chapter_id: row.chapter_id,
147
+ file_path: normalizePathForExport(syncDir, row.file_path),
148
+ prose_checksum: row.prose_checksum ?? null,
149
+ metadata_stale: row.metadata_stale,
150
+ updated_at: row.updated_at,
151
+ }));
152
+
153
+ const baseSnapshot = {
154
+ export: {
155
+ schema_version: STRUCTURE_EXPORT_SCHEMA_VERSION,
156
+ canonical_source: "sqlite",
157
+ project_id: project.project_id,
158
+ generated_transparency: true,
159
+ mutation_surface: false,
160
+ },
161
+ project: {
162
+ project_id: project.project_id,
163
+ universe_id: project.universe_id ?? null,
164
+ name: project.name,
165
+ },
166
+ summary: {
167
+ chapter_count: chapters.length,
168
+ scene_count: scenes.length,
169
+ epigraph_count: epigraphs.length,
170
+ },
171
+ chapters,
172
+ scenes,
173
+ epigraphs,
174
+ };
175
+
176
+ const structureChecksum = sha256(stableStringify(baseSnapshot, 0));
177
+ return {
178
+ ok: true,
179
+ snapshot: {
180
+ ...baseSnapshot,
181
+ export: {
182
+ ...baseSnapshot.export,
183
+ structure_checksum: structureChecksum,
184
+ },
185
+ },
186
+ };
187
+ }
188
+
189
+ export function renderStructureExport(snapshot) {
190
+ return `${stableStringify(snapshot, 2)}\n`;
191
+ }
192
+
193
+ export function writeStructureExportFile(snapshot, { outputDir, fileName }) {
194
+ const normalizedOutputDir = path.resolve(outputDir);
195
+ const targetPath = path.resolve(normalizedOutputDir, fileName);
196
+ const relative = path.relative(normalizedOutputDir, targetPath);
197
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
198
+ throw new Error(`Output file '${fileName}' resolves outside output_dir.`);
199
+ }
200
+
201
+ if (!fs.existsSync(normalizedOutputDir)) {
202
+ fs.mkdirSync(normalizedOutputDir, { recursive: true });
203
+ } else {
204
+ const outputDirStat = fs.lstatSync(normalizedOutputDir);
205
+ if (!outputDirStat.isDirectory()) {
206
+ throw new Error(`output_dir exists but is not a directory: ${normalizedOutputDir}`);
207
+ }
208
+ }
209
+ fs.accessSync(normalizedOutputDir, fs.constants.W_OK);
210
+
211
+ const stat = (() => {
212
+ try {
213
+ return fs.lstatSync(targetPath);
214
+ } catch (error) {
215
+ if (error?.code === "ENOENT") return null;
216
+ throw error;
217
+ }
218
+ })();
219
+ if (stat?.isSymbolicLink()) {
220
+ throw new Error(`Refusing to write: target path is a symlink: ${targetPath}`);
221
+ }
222
+ if (stat && !stat.isFile()) {
223
+ throw new Error(`Refusing to write: target path exists but is not a regular file: ${targetPath}`);
224
+ }
225
+
226
+ fs.writeFileSync(targetPath, renderStructureExport(snapshot), "utf8");
227
+ return targetPath;
228
+ }
package/src/tools/sync.js CHANGED
@@ -5,6 +5,11 @@ import matter from "gray-matter";
5
5
  import { syncAll, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
6
6
  import { importScrivenerSync, validateProjectId } from "../sync/importer.js";
7
7
  import { runStructureDiagnostics } from "../structure/structure-diagnostics.js";
8
+ import {
9
+ buildStructureExport,
10
+ defaultStructureExportFileName,
11
+ writeStructureExportFile,
12
+ } from "../structure/structure-export.js";
8
13
 
9
14
  export function registerSyncTools(s, {
10
15
  db,
@@ -23,6 +28,7 @@ export function registerSyncTools(s, {
23
28
  resolveBatchTargetScenes,
24
29
  maxScenesNextStep,
25
30
  isPathInsideSyncDir,
31
+ resolveOutputDirWithinSync,
26
32
  deriveLoglineFromProse,
27
33
  inferCharacterIdsFromProse,
28
34
  }) {
@@ -67,6 +73,81 @@ export function registerSyncTools(s, {
67
73
  }
68
74
  );
69
75
 
76
+ s.tool(
77
+ "export_structure_snapshot",
78
+ "Generate a deterministic JSON structure export from SQLite canonical state for Git review and future explicit recovery workflows. The export is generated transparency only: editing it does not change canonical state.",
79
+ {
80
+ project_id: z.string().describe("Project ID to export (e.g. 'test-novel')."),
81
+ output_dir: z.string().optional().describe("Directory under WRITING_SYNC_DIR where the export JSON should be written. Defaults to structure-exports."),
82
+ },
83
+ async ({ project_id, output_dir }) => {
84
+ if (!SYNC_DIR_WRITABLE) {
85
+ return errorResponse("READ_ONLY", "Cannot export structure snapshot: sync dir is read-only.");
86
+ }
87
+
88
+ const projectIdCheck = validateProjectId(project_id);
89
+ if (!projectIdCheck.ok) {
90
+ return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
91
+ }
92
+
93
+ try {
94
+ const requestedOutputDir = output_dir
95
+ ? (path.isAbsolute(output_dir) ? output_dir : path.join(SYNC_DIR_ABS, output_dir))
96
+ : path.join(SYNC_DIR_ABS, "structure-exports");
97
+ const { resolvedOutputDir, relativeToSyncDir } = resolveOutputDirWithinSync(requestedOutputDir);
98
+ const outputDirSegments = relativeToSyncDir
99
+ .split(path.sep)
100
+ .filter(Boolean)
101
+ .map(segment => segment.toLowerCase());
102
+ if (outputDirSegments.includes("scenes")) {
103
+ return errorResponse(
104
+ "INVALID_OUTPUT_DIR",
105
+ "output_dir cannot be inside a scenes directory. Choose a dedicated generated export folder under WRITING_SYNC_DIR.",
106
+ { output_dir: resolvedOutputDir }
107
+ );
108
+ }
109
+
110
+ const built = buildStructureExport(db, {
111
+ projectId: project_id,
112
+ syncDir: SYNC_DIR_ABS,
113
+ });
114
+ if (!built.ok) {
115
+ return errorResponse(built.error.code, built.error.message, built.error.details);
116
+ }
117
+
118
+ const fileName = defaultStructureExportFileName(project_id);
119
+ const outputPath = writeStructureExportFile(built.snapshot, {
120
+ outputDir: resolvedOutputDir,
121
+ fileName,
122
+ });
123
+
124
+ return jsonResponse({
125
+ ok: true,
126
+ action: "exported",
127
+ project_id,
128
+ output_path: outputPath,
129
+ relative_output_path: path.join(relativeToSyncDir, fileName).split(path.sep).join("/"),
130
+ export: built.snapshot.export,
131
+ summary: built.snapshot.summary,
132
+ next_step: "Review or commit the generated structure export. Do not edit it as a mutation surface; use explicit structure tools for canonical changes.",
133
+ });
134
+ } catch (error) {
135
+ if (
136
+ error &&
137
+ typeof error === "object" &&
138
+ error.name === "CoreValidationError" &&
139
+ typeof error.code === "string"
140
+ ) {
141
+ return errorResponse(error.code, error.message ?? "Request failed.", error.details);
142
+ }
143
+ return errorResponse(
144
+ "EXPORT_STRUCTURE_FAILED",
145
+ error instanceof Error ? error.message : "Failed to export structure snapshot."
146
+ );
147
+ }
148
+ }
149
+ );
150
+
70
151
  s.tool(
71
152
  "import_scrivener_sync",
72
153
  "[STABLE] Import Scrivener External Folder Sync Draft files into this server's WRITING_SYNC_DIR by generating scene sidecars and reconciling by Scrivener binder ID. This is the recommended default path for first-time setup before sync().",
@@ -89,6 +89,7 @@ export const WORKFLOW_CATALOGUE = [
89
89
  { tool: "move_scene", note: "Use when a scene should move to another canonical chapter and/or unused timeline_position; this does not move the scene source file." },
90
90
  { 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." },
91
91
  { 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." },
92
+ { tool: "export_structure_snapshot", note: "Generate a deterministic SQLite-derived structure export for Git review after canonical structure changes; editing the export does not mutate structure." },
92
93
  ],
93
94
  },
94
95
  {