@hanna84/mcp-writing 3.22.5 → 3.23.0

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,16 @@ 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.23.0](https://github.com/hannasdev/mcp-writing/compare/v3.22.5...v3.23.0)
8
+
9
+ - feat: guard scene relationship metadata updates [`#230`](https://github.com/hannasdev/mcp-writing/pull/230)
10
+
7
11
  #### [v3.22.5](https://github.com/hannasdev/mcp-writing/compare/v3.22.4...v3.22.5)
8
12
 
13
+ > 30 May 2026
14
+
9
15
  - chore(deps): Bump qs in the npm_and_yarn group across 1 directory [`#214`](https://github.com/hannasdev/mcp-writing/pull/214)
16
+ - Release 3.22.5 [`a4146a0`](https://github.com/hannasdev/mcp-writing/commit/a4146a03a9976d022afb75842bee3ce50742fe65)
10
17
 
11
18
  #### [v3.22.4](https://github.com/hannasdev/mcp-writing/compare/v3.22.3...v3.22.4)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.22.5",
3
+ "version": "3.23.0",
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",
@@ -34,6 +34,7 @@ import {
34
34
  } from "../structure/project-backup-refresh.js";
35
35
 
36
36
  const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "chapter_title", "timeline_position"];
37
+ const RELATIONSHIP_SCENE_METADATA_FIELDS = ["characters", "places"];
37
38
 
38
39
  function emptyBackupMutationResult() {
39
40
  return {
@@ -108,6 +109,22 @@ function getProvidedStructuralSceneMetadataFields(fields) {
108
109
  return STRUCTURAL_SCENE_METADATA_FIELDS.filter((field) => Object.hasOwn(fields, field));
109
110
  }
110
111
 
112
+ function getProvidedRelationshipSceneMetadataFields(fields) {
113
+ return RELATIONSHIP_SCENE_METADATA_FIELDS.filter((field) => Object.hasOwn(fields, field));
114
+ }
115
+
116
+ function buildRelationshipMetadataBoundaryDetails({ projectId, sceneId, blockedFields }) {
117
+ return {
118
+ project_id: projectId,
119
+ scene_id: sceneId,
120
+ blocked_fields: blockedFields,
121
+ boundary: "scene_relationship_metadata",
122
+ relationship_tools: ["connect_character_place_evidence", "audit_relationship_metadata"],
123
+ discovery_workflows: ["describe_workflows", "find_scenes", "list_characters", "list_places"],
124
+ next_step: "Use find_scenes, list_characters, and list_places to identify stable IDs. Use connect_character_place_evidence only when the scene proves paired sheet-backed character/place evidence; use audit_relationship_metadata to review legacy sidecar/frontmatter relationship fields. Character-only or place-only scene evidence is not changed through update_scene_metadata.",
125
+ };
126
+ }
127
+
111
128
  function persistReferenceDocLink({ filePath, syncDir, targetDocId, relation }) {
112
129
  const syncDirAbs = path.resolve(syncDir);
113
130
  const syncDirReal = resolveBoundaryRootReal(syncDirAbs);
@@ -437,6 +454,55 @@ function querySceneRelationshipSnapshot(db, { sceneId, projectId }) {
437
454
  };
438
455
  }
439
456
 
457
+ function restoreSceneRelationshipSnapshot(db, { sceneId, projectId, snapshot }) {
458
+ db.prepare(`DELETE FROM scene_characters WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
459
+ db.prepare(`DELETE FROM scene_places WHERE scene_id = ? AND project_id = ?`).run(sceneId, projectId);
460
+
461
+ const insertCharacter = db.prepare(`
462
+ INSERT OR IGNORE INTO scene_characters (scene_id, project_id, character_id)
463
+ VALUES (?, ?, ?)
464
+ `);
465
+ for (const characterId of snapshot.characters) {
466
+ insertCharacter.run(sceneId, projectId, characterId);
467
+ }
468
+
469
+ const insertPlace = db.prepare(`
470
+ INSERT OR IGNORE INTO scene_places (scene_id, project_id, place_id)
471
+ VALUES (?, ?, ?)
472
+ `);
473
+ for (const placeId of snapshot.places) {
474
+ insertPlace.run(sceneId, projectId, placeId);
475
+ }
476
+ }
477
+
478
+ function isVersionContinuityMarker(value) {
479
+ return /^v\d[\d.a-z]*$/i.test(String(value).trim());
480
+ }
481
+
482
+ function buildSceneMetadataSearchKeywords(meta, relationshipSnapshot) {
483
+ const compatibilityVersionMarkers = normalizeStringList(meta.characters).filter(isVersionContinuityMarker);
484
+ return [
485
+ ...normalizeStringList(meta.tags),
486
+ ...compatibilityVersionMarkers,
487
+ ...relationshipSnapshot.characters,
488
+ ...relationshipSnapshot.places,
489
+ ...normalizeStringList(meta.versions),
490
+ ]
491
+ .filter(Boolean)
492
+ .map(String)
493
+ .map((value) => value.trim())
494
+ .filter(Boolean)
495
+ .join(" ");
496
+ }
497
+
498
+ function restoreSceneRelationshipSearchKeywords(db, { sceneId, projectId, meta, snapshot }) {
499
+ db.prepare(`
500
+ UPDATE scenes_fts
501
+ SET keywords = ?
502
+ WHERE scene_id = ? AND project_id = ?
503
+ `).run(buildSceneMetadataSearchKeywords(meta, snapshot), sceneId, projectId);
504
+ }
505
+
440
506
  export function registerMetadataTools(s, {
441
507
  db,
442
508
  SYNC_DIR,
@@ -2214,7 +2280,7 @@ export function registerMetadataTools(s, {
2214
2280
  // ---- update_scene_metadata -----------------------------------------------
2215
2281
  s.tool(
2216
2282
  "update_scene_metadata",
2217
- "Update one or more non-structural metadata fields for a scene. Writes only supplied non-structural fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose or mirrors path-derived structure. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
2283
+ "Update one or more non-structural, non-relationship metadata fields for a scene. Writes only supplied allowed fields to the .meta.yaml sidecar and preserves existing structural compatibility fields; it never modifies prose, mirrors path-derived structure, or changes scene character/place relationship authority. Structural fields (part, chapter, chapter_id, chapter_title, timeline_position) are rejected here; use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structure changes. Relationship fields (characters, places) are rejected here; use discovery workflows plus connect_character_place_evidence for paired sheet-backed character/place evidence, and audit_relationship_metadata for legacy sidecar/frontmatter relationship review. Allowed changes are immediately reflected in the index. Only available when the sync dir is writable.",
2218
2284
  {
2219
2285
  scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
2220
2286
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
@@ -2231,8 +2297,8 @@ export function registerMetadataTools(s, {
2231
2297
  timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
2232
2298
  story_time: z.string().optional(),
2233
2299
  tags: z.array(z.string()).optional(),
2234
- characters: z.array(z.string()).optional(),
2235
- places: z.array(z.string()).optional(),
2300
+ characters: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence for paired sheet-backed evidence, and audit_relationship_metadata for compatibility review."),
2301
+ places: z.array(z.string()).optional().describe("Rejected by update_scene_metadata. Use find_scenes, list_characters, list_places, connect_character_place_evidence for paired sheet-backed evidence, and audit_relationship_metadata for compatibility review."),
2236
2302
  }).describe("Fields to update. Only supplied keys are changed."),
2237
2303
  },
2238
2304
  async ({ scene_id, project_id, fields }) => {
@@ -2244,6 +2310,18 @@ export function registerMetadataTools(s, {
2244
2310
  if (!scene) {
2245
2311
  return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
2246
2312
  }
2313
+ const relationshipFields = getProvidedRelationshipSceneMetadataFields(fields);
2314
+ if (relationshipFields.length > 0) {
2315
+ return errorResponse(
2316
+ "VALIDATION_ERROR",
2317
+ "update_scene_metadata cannot change relationship-boundary fields characters or places. Scene relationship metadata is sheet-backed and must use outcome-level relationship workflows, not generic sidecar metadata writes.",
2318
+ buildRelationshipMetadataBoundaryDetails({
2319
+ projectId: project_id,
2320
+ sceneId: scene_id,
2321
+ blockedFields: relationshipFields,
2322
+ })
2323
+ );
2324
+ }
2247
2325
  const structuralFields = getProvidedStructuralSceneMetadataFields(fields);
2248
2326
  if (structuralFields.length > 0) {
2249
2327
  return errorResponse(
@@ -2258,6 +2336,7 @@ export function registerMetadataTools(s, {
2258
2336
  );
2259
2337
  }
2260
2338
  try {
2339
+ const relationshipSnapshot = querySceneRelationshipSnapshot(db, { sceneId: scene_id, projectId: project_id });
2261
2340
  const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
2262
2341
  const updated = { ...sourceMeta, ...fields };
2263
2342
  writeMeta(scene.file_path, updated, { syncDir: SYNC_DIR });
@@ -2267,6 +2346,17 @@ export function registerMetadataTools(s, {
2267
2346
  indexSceneFile(db, SYNC_DIR, scene.file_path, normalizedUpdated, prose, {
2268
2347
  managedStructure: isManagedStructureProject(db, project_id),
2269
2348
  });
2349
+ restoreSceneRelationshipSnapshot(db, {
2350
+ sceneId: scene_id,
2351
+ projectId: project_id,
2352
+ snapshot: relationshipSnapshot,
2353
+ });
2354
+ restoreSceneRelationshipSearchKeywords(db, {
2355
+ sceneId: scene_id,
2356
+ projectId: project_id,
2357
+ meta: normalizedUpdated,
2358
+ snapshot: relationshipSnapshot,
2359
+ });
2270
2360
  const backupResult = refreshProjectScopedBackupAfterMutation(db, {
2271
2361
  syncDir: SYNC_DIR,
2272
2362
  projectId: project_id,