@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 +20 -0
- package/git.js +28 -16
- package/index.js +5 -1
- package/package.json +1 -1
- package/scrivener-direct.js +125 -11
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
|
|
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
|
|
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
|
|
61
|
-
|
|
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 =
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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:
|
|
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
package/scrivener-direct.js
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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) {
|