@hanna84/mcp-writing 1.6.0 → 1.6.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/index.js +112 -20
  3. package/package.json +1 -1
  4. package/sync.js +48 -0
package/CHANGELOG.md CHANGED
@@ -4,11 +4,21 @@ 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.1](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.6.0...v1.6.1)
9
+
10
+ - fix: preflight prose writes and make canonical sheet creation idempotent [`#46`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/46)
12
+
7
13
  #### [v1.6.0](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v1.5.2...v1.6.0)
9
15
 
16
+ > 19 April 2026
17
+
10
18
  - feat: add permission preflight diagnostics and ops contract [`#45`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/45)
20
+ - Release 1.6.0 [`bfab6d2`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/bfab6d205f4c60bf9e59cb4097796fc7cef63158)
12
22
 
13
23
  #### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v1.5.1...v1.5.2)
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
- if (fs.existsSync(prosePath) || fs.existsSync(metaPath)) {
184
- throw new Error(`Canonical sheet already exists at ${dir}`);
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
- const defaultSheet = kind === "character"
189
- ? renderCharacterSheetTemplate(name)
190
- : renderPlaceSheetTemplate(name);
191
- fs.writeFileSync(prosePath, `${notes?.trim() ?? defaultSheet}${(notes?.trim() ?? defaultSheet) ? "\n" : ""}`, "utf8");
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
- fs.writeFileSync(path.join(dir, "arc.md"), `${renderCharacterArcTemplate(name)}\n`, "utf8");
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. Use this before migrating freeform notes into the new folder structure.",
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 created = createCanonicalWorldEntity({
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", ...created });
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. Use this before migrating freeform notes into the new folder structure.",
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 created = createCanonicalWorldEntity({
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", ...created });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
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",
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
  // ---------------------------------------------------------------------------