@hanna84/mcp-writing 1.6.0 → 1.6.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/README.md +1 -1
- package/index.js +112 -20
- package/package.json +1 -1
- package/sync.js +48 -0
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.6.2](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v1.6.1...v1.6.2)
|
|
9
|
+
|
|
10
|
+
- docs: correct README license footer to AGPL-3.0-only [`#47`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/47)
|
|
12
|
+
|
|
13
|
+
#### [v1.6.1](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.6.0...v1.6.1)
|
|
15
|
+
|
|
16
|
+
> 19 April 2026
|
|
17
|
+
|
|
18
|
+
- fix: preflight prose writes and make canonical sheet creation idempotent [`#46`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/46)
|
|
20
|
+
- Release 1.6.1 [`b456e59`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/b456e59214d736ce250b66729a933645ab370f23)
|
|
22
|
+
|
|
7
23
|
#### [v1.6.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.5.2...v1.6.0)
|
|
9
25
|
|
|
26
|
+
> 19 April 2026
|
|
27
|
+
|
|
10
28
|
- feat: add permission preflight diagnostics and ops contract [`#45`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/45)
|
|
30
|
+
- Release 1.6.0 [`bfab6d2`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/bfab6d205f4c60bf9e59cb4097796fc7cef63158)
|
|
12
32
|
|
|
13
33
|
#### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.5.1...v1.5.2)
|
package/README.md
CHANGED
package/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import matter from "gray-matter";
|
|
|
7
7
|
import yaml from "js-yaml";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { openDb } from "./db.js";
|
|
10
|
-
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
|
|
10
|
+
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
|
|
11
11
|
import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
|
|
12
12
|
import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
|
|
13
13
|
import { importScrivenerSync, validateProjectId } from "./importer.js";
|
|
@@ -180,28 +180,72 @@ function createCanonicalWorldEntity({ kind, name, notes, projectId, universeId,
|
|
|
180
180
|
const { dir } = resolveWorldEntityDir({ kind, projectId, universeId, name });
|
|
181
181
|
const prosePath = path.join(dir, "sheet.md");
|
|
182
182
|
const metaPath = sidecarPath(prosePath);
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
const hadProse = fs.existsSync(prosePath);
|
|
184
|
+
const hadMeta = fs.existsSync(metaPath);
|
|
185
|
+
|
|
186
|
+
let shouldWriteMeta = !hadMeta;
|
|
187
|
+
let payload;
|
|
188
|
+
const derivedId = `${prefix}-${slug}`;
|
|
189
|
+
if (hadMeta) {
|
|
190
|
+
let parsedMeta;
|
|
191
|
+
try {
|
|
192
|
+
parsedMeta = yaml.load(fs.readFileSync(metaPath, "utf8"));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Existing metadata sidecar is invalid YAML at ${metaPath}: ${err.message}`,
|
|
196
|
+
{ cause: err }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (parsedMeta != null && (typeof parsedMeta !== "object" || Array.isArray(parsedMeta))) {
|
|
201
|
+
throw new Error(`Existing metadata sidecar must be a YAML mapping at ${metaPath}.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const existingMeta = parsedMeta ?? {};
|
|
205
|
+
|
|
206
|
+
const backfilledId = existingMeta[idKey] ?? derivedId;
|
|
207
|
+
const backfilledName = existingMeta.name ?? name;
|
|
208
|
+
shouldWriteMeta = existingMeta[idKey] == null || existingMeta.name == null;
|
|
209
|
+
payload = shouldWriteMeta
|
|
210
|
+
? {
|
|
211
|
+
...existingMeta,
|
|
212
|
+
[idKey]: backfilledId,
|
|
213
|
+
name: backfilledName,
|
|
214
|
+
}
|
|
215
|
+
: existingMeta;
|
|
216
|
+
} else {
|
|
217
|
+
payload = {
|
|
218
|
+
[idKey]: derivedId,
|
|
219
|
+
name,
|
|
220
|
+
...(meta ?? {}),
|
|
221
|
+
};
|
|
185
222
|
}
|
|
186
223
|
|
|
187
224
|
fs.mkdirSync(dir, { recursive: true });
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
225
|
+
|
|
226
|
+
if (!hadProse) {
|
|
227
|
+
const defaultSheet = kind === "character"
|
|
228
|
+
? renderCharacterSheetTemplate(name)
|
|
229
|
+
: renderPlaceSheetTemplate(name);
|
|
230
|
+
const body = notes?.trim() ?? defaultSheet;
|
|
231
|
+
fs.writeFileSync(prosePath, `${body}${body ? "\n" : ""}`, "utf8");
|
|
232
|
+
}
|
|
233
|
+
|
|
192
234
|
if (kind === "character") {
|
|
193
|
-
|
|
235
|
+
const arcPath = path.join(dir, "arc.md");
|
|
236
|
+
if (!fs.existsSync(arcPath)) {
|
|
237
|
+
fs.writeFileSync(arcPath, `${renderCharacterArcTemplate(name)}\n`, "utf8");
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (shouldWriteMeta) {
|
|
242
|
+
fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
|
|
194
243
|
}
|
|
195
|
-
const payload = {
|
|
196
|
-
[idKey]: `${prefix}-${slug}`,
|
|
197
|
-
name,
|
|
198
|
-
...meta,
|
|
199
|
-
};
|
|
200
|
-
fs.writeFileSync(metaPath, yaml.dump(payload, { lineWidth: 120 }), "utf8");
|
|
201
244
|
|
|
202
245
|
syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
203
246
|
|
|
204
247
|
return {
|
|
248
|
+
created: !hadProse && !hadMeta,
|
|
205
249
|
id: payload[idKey],
|
|
206
250
|
prose_path: prosePath,
|
|
207
251
|
meta_path: metaPath,
|
|
@@ -711,7 +755,7 @@ function createMcpServer() {
|
|
|
711
755
|
// ---- create_character_sheet ---------------------------------------------
|
|
712
756
|
s.tool(
|
|
713
757
|
"create_character_sheet",
|
|
714
|
-
"Create a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately.
|
|
758
|
+
"Create or reuse a canonical character sheet folder with sheet.md and sheet.meta.yaml so the character can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
|
|
715
759
|
{
|
|
716
760
|
name: z.string().describe("Display name of the character (e.g. 'Mira Nystrom')."),
|
|
717
761
|
project_id: z.string().optional().describe("Project scope for a book-local character (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
|
|
@@ -733,7 +777,7 @@ function createMcpServer() {
|
|
|
733
777
|
}
|
|
734
778
|
|
|
735
779
|
try {
|
|
736
|
-
const
|
|
780
|
+
const result = createCanonicalWorldEntity({
|
|
737
781
|
kind: "character",
|
|
738
782
|
name,
|
|
739
783
|
notes,
|
|
@@ -742,7 +786,7 @@ function createMcpServer() {
|
|
|
742
786
|
meta: fields ?? {},
|
|
743
787
|
});
|
|
744
788
|
|
|
745
|
-
return jsonResponse({ ok: true, action: "created", kind: "character", ...
|
|
789
|
+
return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "character", ...result });
|
|
746
790
|
} catch (err) {
|
|
747
791
|
return errorResponse("IO_ERROR", `Failed to create character sheet: ${err.message}`);
|
|
748
792
|
}
|
|
@@ -777,7 +821,7 @@ function createMcpServer() {
|
|
|
777
821
|
// ---- create_place_sheet -------------------------------------------------
|
|
778
822
|
s.tool(
|
|
779
823
|
"create_place_sheet",
|
|
780
|
-
"Create a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately.
|
|
824
|
+
"Create or reuse a canonical place sheet folder with sheet.md and sheet.meta.yaml so the place can be indexed immediately. If the folder already exists, missing canonical files are backfilled and the existing sheet is preserved.",
|
|
781
825
|
{
|
|
782
826
|
name: z.string().describe("Display name of the place (e.g. 'University Hospital')."),
|
|
783
827
|
project_id: z.string().optional().describe("Project scope for a book-local place (e.g. 'universe-1/book-1-the-lamb' or 'test-novel')."),
|
|
@@ -797,7 +841,7 @@ function createMcpServer() {
|
|
|
797
841
|
}
|
|
798
842
|
|
|
799
843
|
try {
|
|
800
|
-
const
|
|
844
|
+
const result = createCanonicalWorldEntity({
|
|
801
845
|
kind: "place",
|
|
802
846
|
name,
|
|
803
847
|
notes,
|
|
@@ -806,7 +850,7 @@ function createMcpServer() {
|
|
|
806
850
|
meta: fields ?? {},
|
|
807
851
|
});
|
|
808
852
|
|
|
809
|
-
return jsonResponse({ ok: true, action: "created", kind: "place", ...
|
|
853
|
+
return jsonResponse({ ok: true, action: result.created ? "created" : "exists", kind: "place", ...result });
|
|
810
854
|
} catch (err) {
|
|
811
855
|
return errorResponse("IO_ERROR", `Failed to create place sheet: ${err.message}`);
|
|
812
856
|
}
|
|
@@ -1383,6 +1427,54 @@ function createMcpServer() {
|
|
|
1383
1427
|
}
|
|
1384
1428
|
|
|
1385
1429
|
try {
|
|
1430
|
+
const proseWriteDiagnostics = getFileWriteDiagnostics(proposal.scene_file_path);
|
|
1431
|
+
if (proseWriteDiagnostics.stat_error_code === "EACCES" || proseWriteDiagnostics.stat_error_code === "EPERM") {
|
|
1432
|
+
return errorResponse(
|
|
1433
|
+
"PROSE_FILE_NOT_WRITABLE",
|
|
1434
|
+
"Scene prose file cannot be accessed by the current runtime user.",
|
|
1435
|
+
{
|
|
1436
|
+
indexed_path: proposal.scene_file_path,
|
|
1437
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1438
|
+
}
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (proseWriteDiagnostics.stat_error_code && proseWriteDiagnostics.stat_error_code !== "ENOENT" && proseWriteDiagnostics.stat_error_code !== "ENOTDIR") {
|
|
1443
|
+
return errorResponse(
|
|
1444
|
+
"IO_ERROR",
|
|
1445
|
+
"Failed to inspect scene prose path before writing.",
|
|
1446
|
+
{
|
|
1447
|
+
indexed_path: proposal.scene_file_path,
|
|
1448
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1449
|
+
}
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (!proseWriteDiagnostics.exists) {
|
|
1454
|
+
return errorResponse("STALE_PATH", "Prose file not found at indexed path.", {
|
|
1455
|
+
indexed_path: proposal.scene_file_path,
|
|
1456
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
if (!proseWriteDiagnostics.is_file) {
|
|
1461
|
+
return errorResponse("INVALID_PROSE_PATH", "Indexed prose path is not a regular file.", {
|
|
1462
|
+
indexed_path: proposal.scene_file_path,
|
|
1463
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
if (!proseWriteDiagnostics.writable) {
|
|
1468
|
+
return errorResponse(
|
|
1469
|
+
"PROSE_FILE_NOT_WRITABLE",
|
|
1470
|
+
"Scene prose file is not writable by the current runtime user.",
|
|
1471
|
+
{
|
|
1472
|
+
indexed_path: proposal.scene_file_path,
|
|
1473
|
+
prose_write_diagnostics: proseWriteDiagnostics,
|
|
1474
|
+
}
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1386
1478
|
// Reconstruct file content, preserving frontmatter only if the original had it
|
|
1387
1479
|
const hasFrontmatter = proposal.metadata && Object.keys(proposal.metadata).length > 0;
|
|
1388
1480
|
const content = hasFrontmatter
|
package/package.json
CHANGED
package/sync.js
CHANGED
|
@@ -297,6 +297,54 @@ export function getSyncOwnershipDiagnostics(syncDir, { sampleLimit = 200 } = {})
|
|
|
297
297
|
return diagnostics;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
export function getFileWriteDiagnostics(filePath) {
|
|
301
|
+
const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
302
|
+
const resolvedPath = path.resolve(filePath);
|
|
303
|
+
const parentDir = path.dirname(resolvedPath);
|
|
304
|
+
const diagnostics = {
|
|
305
|
+
path: resolvedPath,
|
|
306
|
+
parent_dir: parentDir,
|
|
307
|
+
exists: false,
|
|
308
|
+
is_file: false,
|
|
309
|
+
writable: false,
|
|
310
|
+
parent_dir_writable: false,
|
|
311
|
+
supported: runtimeUid !== null,
|
|
312
|
+
runtime_uid: runtimeUid,
|
|
313
|
+
owner_uid: null,
|
|
314
|
+
root_owned: false,
|
|
315
|
+
stat_error_code: null,
|
|
316
|
+
stat_error_message: null,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
fs.accessSync(parentDir, fs.constants.W_OK);
|
|
321
|
+
diagnostics.parent_dir_writable = true;
|
|
322
|
+
} catch {
|
|
323
|
+
diagnostics.parent_dir_writable = false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const stat = fs.statSync(resolvedPath);
|
|
328
|
+
diagnostics.exists = true;
|
|
329
|
+
diagnostics.is_file = stat.isFile();
|
|
330
|
+
diagnostics.owner_uid = typeof stat.uid === "number" ? stat.uid : null;
|
|
331
|
+
diagnostics.root_owned = stat.uid === 0;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
diagnostics.stat_error_code = typeof err?.code === "string" ? err.code : null;
|
|
334
|
+
diagnostics.stat_error_message = typeof err?.message === "string" ? err.message : String(err);
|
|
335
|
+
return diagnostics;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
fs.accessSync(resolvedPath, fs.constants.W_OK);
|
|
340
|
+
diagnostics.writable = diagnostics.is_file;
|
|
341
|
+
} catch {
|
|
342
|
+
diagnostics.writable = false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return diagnostics;
|
|
346
|
+
}
|
|
347
|
+
|
|
300
348
|
// ---------------------------------------------------------------------------
|
|
301
349
|
// DB-dependent sync (takes db + syncDir as arguments for testability)
|
|
302
350
|
// ---------------------------------------------------------------------------
|