@hanna84/mcp-writing 1.13.0 → 1.13.2

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,11 +4,31 @@ 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
+ #### [v1.13.2](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.13.1...v1.13.2)
9
+
10
+ - fix(scrivener-direct): add Phase E data safety hardening [`#71`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/71)
12
+
13
+ #### [v1.13.1](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.13.0...v1.13.1)
15
+
16
+ > 24 April 2026
17
+
18
+ - fix(mcp): report server version from package metadata [`#70`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/70)
20
+ - Release 1.13.1 [`c9a5ff5`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/c9a5ff5e847767cad2d73c170af7b28aede1ec59)
22
+
7
23
  #### [v1.13.0](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.12.0...v1.13.0)
9
25
 
26
+ > 24 April 2026
27
+
10
28
  - feat(scrivener-direct): add ambiguity warning taxonomy for beta merge [`#69`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/69)
30
+ - Release 1.13.0 [`d46f4bd`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/d46f4bdbb7c92a1d5b43ae8ed0afdf76a7a2cb62)
12
32
 
13
33
  #### [v1.12.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.11.8...v1.12.0)
package/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
 
@@ -14,7 +14,7 @@ export function isGitRepository(dirPath) {
14
14
  stdio: "pipe",
15
15
  encoding: "utf8",
16
16
  }).trim();
17
- return path.resolve(gitRoot) === path.resolve(dirPath);
17
+ return fs.realpathSync(gitRoot) === fs.realpathSync(dirPath);
18
18
  } catch {
19
19
  return false;
20
20
  }
@@ -52,26 +52,35 @@ export function isGitAvailable() {
52
52
  }
53
53
 
54
54
  /**
55
- * Create a git commit for a scene file (pre-edit snapshot)
55
+ * Create a git commit for one or more scene-related files
56
56
  * Returns { commit_hash: string, commit_message: string }
57
57
  */
58
- export function createSnapshot(dirPath, filePath, sceneId, instruction) {
58
+ export function createSnapshot(dirPath, filePath, sceneId, instruction, options = {}) {
59
59
  try {
60
- const relPath = path.relative(dirPath, filePath);
61
- execSync(`git add "${relPath}"`, { cwd: dirPath, stdio: "pipe" });
60
+ const { messagePrefix = "pre-edit snapshot" } = options;
61
+ const inputPaths = Array.isArray(filePath) ? filePath : [filePath];
62
+ const relPaths = [...new Set(inputPaths
63
+ .filter(Boolean)
64
+ .map(p => path.relative(dirPath, p)))];
65
+ if (!relPaths.length) {
66
+ throw new Error("No file paths provided for snapshot");
67
+ }
68
+ // Use -A so removed/renamed paths are staged as part of relocation snapshots.
69
+ execFileSync("git", ["add", "-A", "--", ...relPaths], { cwd: dirPath, stdio: "pipe" });
62
70
 
63
- const commitMessage = `pre-edit snapshot: ${sceneId} — ${instruction}`;
64
- // Use 2>&1 so git's stderr (where it prints "[branch hash] msg") is captured in stdout
65
- const output = execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}" 2>&1`, {
71
+ const commitMessage = messagePrefix
72
+ ? `${messagePrefix}: ${sceneId} ${instruction}`
73
+ : String(instruction);
74
+
75
+ execFileSync("git", ["commit", "-m", commitMessage], {
66
76
  cwd: dirPath,
67
- encoding: "utf8",
68
77
  stdio: "pipe",
69
78
  });
70
-
71
- // git outputs "[branch hash] message" to stderr; redirect 2>&1 captures it in stdout
72
- // Regex handles any branch name, with or without (root-commit)
73
- const match = output.match(/\[\S+(?:\s+\(root-commit\))?\s+([a-f0-9]+)\]/);
74
- const commitHash = match ? match[1] : null;
79
+ const commitHash = execFileSync("git", ["rev-parse", "HEAD"], {
80
+ cwd: dirPath,
81
+ stdio: "pipe",
82
+ encoding: "utf8",
83
+ }).trim();
75
84
 
76
85
  return {
77
86
  commit_hash: commitHash,
@@ -79,7 +88,10 @@ export function createSnapshot(dirPath, filePath, sceneId, instruction) {
79
88
  };
80
89
  } catch (err) {
81
90
  // Check if nothing changed (no error, just no commit)
82
- if (err.message.includes("nothing to commit") || err.status === 1) {
91
+ const stderr = err?.stderr ? String(err.stderr) : "";
92
+ const stdout = err?.stdout ? String(err.stdout) : "";
93
+ const text = `${stderr}\n${stdout}\n${err?.message ?? ""}`;
94
+ if (text.includes("nothing to commit") || err.status === 1) {
83
95
  return {
84
96
  commit_hash: null,
85
97
  commit_message: null,
package/index.js CHANGED
@@ -78,6 +78,10 @@ const OWNERSHIP_GUARD_MODE = OWNERSHIP_GUARD_MODE_RAW === "fail" || OWNERSHIP_GU
78
78
  const OWNERSHIP_GUARD_MODE_RAW_DISPLAY = JSON.stringify(OWNERSHIP_GUARD_MODE_RAW);
79
79
  const __filename = fileURLToPath(import.meta.url);
80
80
  const __dirname = path.dirname(__filename);
81
+ const pkg = readJsonIfExists(path.join(__dirname, "package.json")) ?? {};
82
+ const MCP_SERVER_VERSION = typeof pkg.version === "string" && pkg.version.trim()
83
+ ? pkg.version
84
+ : "0.0.0";
81
85
  const asyncJobs = new Map();
82
86
 
83
87
  function pruneAsyncJobs() {
@@ -753,7 +757,7 @@ async function gracefulShutdown(signal) {
753
757
  // MCP server factory
754
758
  // ---------------------------------------------------------------------------
755
759
  function createMcpServer() {
756
- const s = new McpServer({ name: "mcp-writing", version: "0.1.0" });
760
+ const s = new McpServer({ name: "mcp-writing", version: MCP_SERVER_VERSION });
757
761
 
758
762
  // ---- sync ----------------------------------------------------------------
759
763
  s.tool("sync", "Re-scan the sync folder and update the scene/character/place index from disk. Call this after making edits in Scrivener or updating sidecar files outside the MCP.", {}, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
4
4
  "description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { DOMParser } from "@xmldom/xmldom";
4
4
  import yaml from "js-yaml";
5
5
  import { validateProjectId } from "./importer.js";
6
+ import { createSnapshot, isGitRepository, isGitAvailable } from "./git.js";
6
7
 
7
8
  function attr(el, name) {
8
9
  return el?.getAttribute?.(name) ?? null;
@@ -93,8 +94,60 @@ function moveFileIfNeeded(fromPath, toPath) {
93
94
  if (!error || typeof error !== "object" || error.code !== "EXDEV") {
94
95
  throw error;
95
96
  }
96
- fs.copyFileSync(fromPath, toPath);
97
- fs.unlinkSync(fromPath);
97
+ // Cross-filesystem: copy then verify before unlink
98
+ try {
99
+ fs.copyFileSync(fromPath, toPath);
100
+ // Verify copy succeeded before deleting source
101
+ if (!fs.existsSync(toPath)) {
102
+ return {
103
+ moved: false,
104
+ warning: {
105
+ code: "relocate_copy_verification_failed",
106
+ message: "Failed to verify file copy to destination; source file preserved.",
107
+ from_path: fromPath,
108
+ to_path: toPath,
109
+ },
110
+ };
111
+ }
112
+ try {
113
+ fs.unlinkSync(fromPath);
114
+ } catch (unlinkErr) {
115
+ if (fs.existsSync(toPath)) {
116
+ try {
117
+ fs.unlinkSync(toPath);
118
+ } catch {
119
+ // Best effort; already in error state
120
+ }
121
+ }
122
+ return {
123
+ moved: false,
124
+ warning: {
125
+ code: "relocate_cross_filesystem_unlink_failed",
126
+ message: `Copied prose file but failed to remove source file: ${unlinkErr.message}. Source file preserved.`,
127
+ from_path: fromPath,
128
+ to_path: toPath,
129
+ },
130
+ };
131
+ }
132
+ } catch (copyErr) {
133
+ // Copy failed; ensure we don't leave partial destination files
134
+ if (fs.existsSync(toPath)) {
135
+ try {
136
+ fs.unlinkSync(toPath);
137
+ } catch {
138
+ // Best effort; already in error state
139
+ }
140
+ }
141
+ return {
142
+ moved: false,
143
+ warning: {
144
+ code: "relocate_cross_filesystem_copy_failed",
145
+ message: `Failed to copy prose file across filesystem: ${copyErr.message}. Source file preserved.`,
146
+ from_path: fromPath,
147
+ to_path: toPath,
148
+ },
149
+ };
150
+ }
98
151
  }
99
152
 
100
153
  return { moved: true };
@@ -339,7 +392,8 @@ export function mergeSidecarData(existing, mergeData) {
339
392
  };
340
393
  }
341
394
 
342
- export function loadScrivenerProjectData(scrivPath) {
395
+ export function loadScrivenerProjectData(scrivPath, options = {}) {
396
+ const { onWarning } = options;
343
397
  const scrivPathAbs = path.resolve(scrivPath);
344
398
  if (!fs.existsSync(scrivPathAbs)) {
345
399
  throw new Error(`Scrivener bundle not found: ${scrivPathAbs}`);
@@ -361,6 +415,19 @@ export function loadScrivenerProjectData(scrivPath) {
361
415
  const scrivxPath = path.join(scrivPathAbs, preferredScrivx);
362
416
  const dataDir = path.join(scrivPathAbs, "Files", "Data");
363
417
 
418
+ // XML size guardrail: surface warning if .scrivx is very large.
419
+ const scrivxStat = fs.statSync(scrivxPath);
420
+ const MAX_SCRIVX_SIZE = 50 * 1024 * 1024; // 50MB
421
+ if (scrivxStat.size > MAX_SCRIVX_SIZE) {
422
+ onWarning?.({
423
+ code: "large_scrivx_file",
424
+ message: `Scrivener project .scrivx file is unusually large (${Math.round(scrivxStat.size / 1024 / 1024)}MB). This is an advisory warning; parsing will continue but may take longer and use significantly more memory because XML is parsed in-memory.`,
425
+ scrivx_path: scrivxPath,
426
+ size_bytes: scrivxStat.size,
427
+ threshold_bytes: MAX_SCRIVX_SIZE,
428
+ });
429
+ }
430
+
364
431
  const xml = fs.readFileSync(scrivxPath, "utf8");
365
432
  const dom = new DOMParser().parseFromString(xml, "text/xml");
366
433
 
@@ -517,7 +584,16 @@ export function mergeScrivenerProjectMetadata({
517
584
  throw new Error(`Scenes directory not found or not a directory: ${scenesDir}`);
518
585
  }
519
586
 
520
- const projectData = loadScrivenerProjectData(scrivPath);
587
+ const warnings = [];
588
+ const warningSummary = {};
589
+ let warningsTruncated = false;
590
+
591
+ const projectData = loadScrivenerProjectData(scrivPath, {
592
+ onWarning: (warning) => {
593
+ warningsTruncated = pushWarning(warnings, warningSummary, warning) || warningsTruncated;
594
+ logger(` WARN ${warning.message}`);
595
+ },
596
+ });
521
597
  logger(`Sync map: ${Object.keys(projectData.syncNumToUUID).length} entries`);
522
598
  logger(`Keyword map: ${Object.keys(projectData.keywordMap).length} entries`);
523
599
  logger(`Binder items collected: ${Object.keys(projectData.metaByUUID).length}`);
@@ -533,10 +609,7 @@ export function mergeScrivenerProjectMetadata({
533
609
  let relocated = 0;
534
610
  const fieldAddCounts = {};
535
611
  const previewChanges = [];
536
- const warnings = [];
537
- const warningSummary = {};
538
- let warningsTruncated = false;
539
-
612
+ const canCreateSnapshots = !dryRun && isGitAvailable() && isGitRepository(mcpSyncDirAbs);
540
613
  for (const sidecarPath of sidecarFiles) {
541
614
  const filename = path.basename(sidecarPath);
542
615
  const prosePath = findProsePathForSidecar(sidecarPath);
@@ -599,7 +672,20 @@ export function mergeScrivenerProjectMetadata({
599
672
  const targetProsePath = prosePath
600
673
  ? (organizeByChapters ? path.join(targetDir, path.basename(prosePath)) : prosePath)
601
674
  : null;
602
- const needsMove = path.resolve(sidecarPath) !== path.resolve(targetSidecarPath)
675
+ const desiredSidecarRelocation = organizeByChapters
676
+ && path.resolve(sidecarPath) !== path.resolve(targetSidecarPath);
677
+
678
+ // Orphan detection: warn once if sidecar has no matching prose and relocation would be attempted.
679
+ if (!prosePath && desiredSidecarRelocation) {
680
+ warningsTruncated = pushWarning(warnings, warningSummary, {
681
+ code: "sidecar_missing_prose",
682
+ message: "Sidecar has no matching prose file (.md or .txt); relocation skipped to preserve consistency.",
683
+ file: filename,
684
+ uuid,
685
+ }) || warningsTruncated;
686
+ }
687
+
688
+ const needsMove = desiredSidecarRelocation
603
689
  || (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath));
604
690
 
605
691
  if (!changed && !needsMove) {
@@ -629,10 +715,14 @@ export function mergeScrivenerProjectMetadata({
629
715
  if (needsMove) {
630
716
  logger(` -> ${path.relative(scenesDir, targetSidecarPath) || filename}`);
631
717
  }
632
- didRelocate = needsMove;
718
+ const dryRunRelocateEligible = desiredSidecarRelocation
719
+ && prosePath
720
+ && (!fs.existsSync(targetSidecarPath))
721
+ && (!targetProsePath || path.resolve(prosePath) === path.resolve(targetProsePath) || !fs.existsSync(targetProsePath));
722
+ didRelocate = needsMove && dryRunRelocateEligible;
633
723
  } else {
634
724
  let proseMoveWarning = null;
635
- let shouldRelocateSidecar = organizeByChapters;
725
+ let shouldRelocateSidecar = desiredSidecarRelocation;
636
726
 
637
727
  if (
638
728
  shouldRelocateSidecar
@@ -653,6 +743,11 @@ export function mergeScrivenerProjectMetadata({
653
743
  ) || warningsTruncated;
654
744
  }
655
745
 
746
+ // Prevent relocation if sidecar is orphaned (no matching prose)
747
+ if (shouldRelocateSidecar && !prosePath) {
748
+ shouldRelocateSidecar = false;
749
+ }
750
+
656
751
  if (shouldRelocateSidecar && prosePath && targetProsePath) {
657
752
  const moveResult = moveFileIfNeeded(prosePath, targetProsePath);
658
753
  if (moveResult?.warning) {
@@ -680,6 +775,25 @@ export function mergeScrivenerProjectMetadata({
680
775
  fs.unlinkSync(sidecarPath);
681
776
  }
682
777
 
778
+ // Create git snapshot for audit trail (only on actual writes, not dry-run)
779
+ if (canCreateSnapshots) {
780
+ try {
781
+ const sceneId = existing?.scene_id ?? `[${syncNum}]`;
782
+ const commitMessage = `beta merge Scrivener project metadata [${uuid}]`;
783
+ let snapshotPaths = finalSidecarPath;
784
+ if (shouldRelocateSidecar) {
785
+ snapshotPaths = [finalSidecarPath, sidecarPath];
786
+ if (prosePath && targetProsePath && path.resolve(prosePath) !== path.resolve(targetProsePath)) {
787
+ snapshotPaths.push(prosePath, targetProsePath);
788
+ }
789
+ }
790
+ createSnapshot(mcpSyncDirAbs, snapshotPaths, sceneId, commitMessage, { messagePrefix: null });
791
+ } catch (err) {
792
+ // Snapshot creation errors should not block the merge; log and continue
793
+ logger(` WARN git snapshot creation failed for ${filename}: ${err.message}`);
794
+ }
795
+ }
796
+
683
797
  const changes = [];
684
798
  if (newKeys.length) changes.push(`+${newKeys.join(", ")}`);
685
799
  if (needsMove && shouldRelocateSidecar) {