@hanna84/mcp-writing 3.21.2 → 3.21.3

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.21.3](https://github.com/hannasdev/mcp-writing/compare/v3.21.2...v3.21.3)
8
+
9
+ - fix(metadata): preserve sidecar structure fields on generic writes [`#224`](https://github.com/hannasdev/mcp-writing/pull/224)
10
+
7
11
  #### [v3.21.2](https://github.com/hannasdev/mcp-writing/compare/v3.21.1...v3.21.2)
8
12
 
13
+ > 28 May 2026
14
+
9
15
  - docs: accept architecture snapshot milestone [`#223`](https://github.com/hannasdev/mcp-writing/pull/223)
16
+ - Release 3.21.2 [`7f0e627`](https://github.com/hannasdev/mcp-writing/commit/7f0e62741059ad487577e7295ea75564fd64d33a)
10
17
 
11
18
  #### [v3.21.1](https://github.com/hannasdev/mcp-writing/compare/v3.21.0...v3.21.1)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.21.2",
3
+ "version": "3.21.3",
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",
@@ -2,7 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { openDb } from "../core/db.js";
4
4
  import { buildCharacterNormalizationContext, normalizeSceneCharacters } from "../sync/scene-character-normalization.js";
5
- import { normalizeSceneMetaForPath, readMeta, syncAll, writeMeta } from "../sync/sync.js";
5
+ import { readMeta, readSourceMeta, syncAll, writeMeta } from "../sync/sync.js";
6
6
 
7
7
  function readRequiredValue(argv, index, option) {
8
8
  const value = argv[index + 1];
@@ -138,11 +138,11 @@ function runNormalization({ syncDir, projectId, write, limit }) {
138
138
  if (!normalized.changed) continue;
139
139
 
140
140
  if (write) {
141
- const updatedMeta = normalizeSceneMetaForPath(syncDir, scene.file_path, {
142
- ...meta,
141
+ const { sourceMeta } = readSourceMeta(scene.file_path, syncDir, { writable: true });
142
+ writeMeta(scene.file_path, {
143
+ ...sourceMeta,
143
144
  characters: normalized.after,
144
- }).meta;
145
- writeMeta(scene.file_path, updatedMeta, { syncDir });
145
+ }, { syncDir });
146
146
  }
147
147
 
148
148
  changed.push({
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import matter from "gray-matter";
3
3
  import { buildCharacterNormalizationContext, escapeRegex, resolveCharacterReference } from "./scene-character-normalization.js";
4
- import { normalizeSceneMetaForPath, readMeta, writeMeta } from "./sync.js";
4
+ import { readSourceMeta, writeMeta } from "./sync.js";
5
5
 
6
6
  function normalizeCharacterRows(rows) {
7
7
  return buildCharacterNormalizationContext(rows);
@@ -138,7 +138,7 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
138
138
  try {
139
139
  const raw = fs.readFileSync(scene.file_path, "utf8");
140
140
  const { content: prose } = matter(raw);
141
- const { meta } = readMeta(scene.file_path, syncDir, { writable: !dry_run });
141
+ const { sourceMeta: meta } = readSourceMeta(scene.file_path, syncDir, { writable: !dry_run });
142
142
 
143
143
  const before_characters = [...new Set((meta.characters ?? []).map(String).filter(Boolean))];
144
144
  const normalized_before_characters = [...new Set(
@@ -169,10 +169,10 @@ export async function runSceneCharacterBatch({ syncDir, args, onProgress, should
169
169
  const changed = added.length > 0 || removed.length > 0;
170
170
 
171
171
  if (!dry_run && changed) {
172
- const updatedMeta = normalizeSceneMetaForPath(syncDir, scene.file_path, {
172
+ const updatedMeta = {
173
173
  ...meta,
174
174
  characters: after_characters,
175
- }).meta;
175
+ };
176
176
 
177
177
  writeMeta(scene.file_path, updatedMeta, { syncDir });
178
178
  }
package/src/sync/sync.js CHANGED
@@ -494,11 +494,12 @@ export function parseFile(filePath) {
494
494
  }
495
495
 
496
496
  /**
497
- * Read metadata for a scene file. Priority: sidecar > frontmatter.
498
- * If sidecar doesn't exist but frontmatter does, auto-generates the sidecar.
499
- * Returns { meta, sidecarGenerated }.
497
+ * Read source metadata for a scene file. Priority: sidecar > frontmatter.
498
+ * Does not apply path-derived structural normalization or auto-generate sidecars.
499
+ * Generic metadata writers use this so non-structural updates do not mirror
500
+ * path-derived structure fields into sidecars.
500
501
  */
501
- export function readMeta(filePath, syncDir, { writable = false } = {}) {
502
+ export function readSourceMeta(filePath, syncDir, { writable = false } = {}) {
502
503
  const sidecar = sidecarPath(filePath);
503
504
  const syncDirAbs = path.resolve(syncDir);
504
505
 
@@ -517,26 +518,45 @@ export function readMeta(filePath, syncDir, { writable = false } = {}) {
517
518
  }
518
519
  const raw = fs.readFileSync(sidecar, "utf8");
519
520
  const parsed = parseYaml(raw) ?? {};
520
- return { ...normalizeSceneMetaForPath(syncDir, filePath, parsed), sourceMeta: parsed, sidecarGenerated: false };
521
+ return { meta: parsed, sourceMeta: parsed, source: "sidecar", sidecarGenerated: false };
521
522
  }
522
523
 
523
524
  // Fall back to frontmatter
524
525
  const { data: frontmatter } = parseFile(filePath);
525
526
  if (!Object.keys(frontmatter).length) {
526
- return { ...normalizeSceneMetaForPath(syncDir, filePath, {}), sourceMeta: {}, sidecarGenerated: false };
527
+ return { meta: {}, sourceMeta: {}, source: "empty", sidecarGenerated: false };
528
+ }
529
+
530
+ return { meta: frontmatter, sourceMeta: frontmatter, source: "frontmatter", sidecarGenerated: false };
531
+ }
532
+
533
+ /**
534
+ * Read metadata for a scene file. Priority: sidecar > frontmatter.
535
+ * If sidecar doesn't exist but frontmatter does, auto-generates the sidecar.
536
+ * Returns { meta, sidecarGenerated }.
537
+ */
538
+ export function readMeta(filePath, syncDir, { writable = false } = {}) {
539
+ const sourceRead = readSourceMeta(filePath, syncDir, { writable });
540
+
541
+ if (sourceRead.source === "sidecar" || sourceRead.source === "empty") {
542
+ return {
543
+ ...normalizeSceneMetaForPath(syncDir, filePath, sourceRead.sourceMeta),
544
+ sourceMeta: sourceRead.sourceMeta,
545
+ sidecarGenerated: false,
546
+ };
527
547
  }
528
548
 
529
- const normalized = normalizeSceneMetaForPath(syncDir, filePath, frontmatter);
549
+ const normalized = normalizeSceneMetaForPath(syncDir, filePath, sourceRead.sourceMeta);
530
550
 
531
551
  // Auto-migrate: write sidecar from frontmatter (only if writable)
532
552
  if (writable) {
533
553
  try {
534
554
  writeMeta(filePath, normalized.meta, { syncDir });
535
- return { ...normalized, sourceMeta: frontmatter, sidecarGenerated: true };
555
+ return { ...normalized, sourceMeta: sourceRead.sourceMeta, sidecarGenerated: true };
536
556
  } catch { /* empty */ }
537
557
  }
538
558
 
539
- return { ...normalized, sourceMeta: frontmatter, sidecarGenerated: false };
559
+ return { ...normalized, sourceMeta: sourceRead.sourceMeta, sidecarGenerated: false };
540
560
  }
541
561
 
542
562
  /**
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import matter from "gray-matter";
5
- import { readMeta, writeMeta, indexSceneFile, isManagedStructureProject } from "../sync/sync.js";
5
+ import { readMeta, readSourceMeta, writeMeta, indexSceneFile, isManagedStructureProject, normalizeSceneMetaForPath } from "../sync/sync.js";
6
6
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
7
7
  import { resolveValidatedChapterFilter } from "../core/chapter-resolution.js";
8
8
  import {
@@ -33,7 +33,7 @@ import {
33
33
  refreshProjectBackupAfterMutation,
34
34
  } from "../structure/project-backup-refresh.js";
35
35
 
36
- const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "timeline_position"];
36
+ const STRUCTURAL_SCENE_METADATA_FIELDS = ["part", "chapter", "chapter_id", "chapter_title", "timeline_position"];
37
37
 
38
38
  function emptyBackupMutationResult() {
39
39
  return {
@@ -1641,7 +1641,7 @@ export function registerMetadataTools(s, {
1641
1641
  // ---- update_scene_metadata -----------------------------------------------
1642
1642
  s.tool(
1643
1643
  "update_scene_metadata",
1644
- "Update one or more non-structural metadata fields for a scene. Writes to the .meta.yaml sidecar never modifies prose. Structural fields (part, chapter, chapter_id, timeline_position) are rejected here; use assign_scene_to_chapter or move_scene for chapter placement and ordering. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
1644
+ "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.",
1645
1645
  {
1646
1646
  scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
1647
1647
  project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
@@ -1654,6 +1654,7 @@ export function registerMetadataTools(s, {
1654
1654
  part: z.number().int().optional().describe("Rejected by update_scene_metadata. Structural placement must use explicit structure workflows."),
1655
1655
  chapter: z.number().int().optional().describe("Rejected by update_scene_metadata. Use assign_scene_to_chapter or move_scene with canonical chapter_id."),
1656
1656
  chapter_id: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use list_chapters, then assign_scene_to_chapter or move_scene."),
1657
+ chapter_title: z.string().nullable().optional().describe("Rejected by update_scene_metadata. Use rename_chapter for canonical chapter title changes."),
1657
1658
  timeline_position: z.number().int().optional().describe("Rejected by update_scene_metadata. Use move_scene for ordering changes."),
1658
1659
  story_time: z.string().optional(),
1659
1660
  tags: z.array(z.string()).optional(),
@@ -1674,22 +1675,23 @@ export function registerMetadataTools(s, {
1674
1675
  if (structuralFields.length > 0) {
1675
1676
  return errorResponse(
1676
1677
  "VALIDATION_ERROR",
1677
- "update_scene_metadata cannot change structural fields. Use assign_scene_to_chapter for chapter assignment or move_scene for chapter and timeline placement.",
1678
+ "update_scene_metadata cannot change structural fields. Use list_chapters plus assign_scene_to_chapter, move_scene, rename_chapter, or reorder_chapter for structural changes.",
1678
1679
  {
1679
1680
  project_id,
1680
1681
  scene_id,
1681
1682
  blocked_fields: structuralFields,
1682
- allowed_structure_tools: ["assign_scene_to_chapter", "move_scene"],
1683
+ allowed_structure_tools: ["list_chapters", "assign_scene_to_chapter", "move_scene", "rename_chapter", "reorder_chapter"],
1683
1684
  }
1684
1685
  );
1685
1686
  }
1686
1687
  try {
1687
- const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
1688
- const updated = { ...meta, ...fields };
1688
+ const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
1689
+ const updated = { ...sourceMeta, ...fields };
1689
1690
  writeMeta(scene.file_path, updated, { syncDir: SYNC_DIR });
1691
+ const normalizedUpdated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, updated).meta;
1690
1692
 
1691
1693
  const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
1692
- indexSceneFile(db, SYNC_DIR, scene.file_path, updated, prose, {
1694
+ indexSceneFile(db, SYNC_DIR, scene.file_path, normalizedUpdated, prose, {
1693
1695
  managedStructure: isManagedStructureProject(db, project_id),
1694
1696
  });
1695
1697
  const backupResult = refreshProjectScopedBackupAfterMutation(db, {
@@ -1707,7 +1709,7 @@ export function registerMetadataTools(s, {
1707
1709
  scene_id,
1708
1710
  project_id,
1709
1711
  fields: Object.keys(fields).sort().reduce((acc, key) => {
1710
- acc[key] = meta[key] ?? null;
1712
+ acc[key] = sourceMeta[key] ?? null;
1711
1713
  return acc;
1712
1714
  }, {}),
1713
1715
  },
@@ -1717,7 +1719,7 @@ export function registerMetadataTools(s, {
1717
1719
  scene_id,
1718
1720
  project_id,
1719
1721
  fields: Object.keys(fields).sort().reduce((acc, key) => {
1720
- acc[key] = updated[key] ?? null;
1722
+ acc[key] = normalizedUpdated[key] ?? null;
1721
1723
  return acc;
1722
1724
  }, {}),
1723
1725
  },
@@ -1928,10 +1930,10 @@ export function registerMetadataTools(s, {
1928
1930
  return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
1929
1931
  }
1930
1932
  try {
1931
- const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
1932
- const flags = meta.flags ?? [];
1933
+ const { sourceMeta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
1934
+ const flags = sourceMeta.flags ?? [];
1933
1935
  flags.push({ note, flagged_at: new Date().toISOString() });
1934
- writeMeta(scene.file_path, { ...meta, flags }, { syncDir: SYNC_DIR });
1936
+ writeMeta(scene.file_path, { ...sourceMeta, flags }, { syncDir: SYNC_DIR });
1935
1937
  return { content: [{ type: "text", text: `Flagged scene '${scene_id}': ${note}` }] };
1936
1938
  } catch (err) {
1937
1939
  if (err?.name === "CoreValidationError") {
@@ -1,4 +1,4 @@
1
- import { readMeta, writeMeta, normalizeReferenceLinkList } from "../sync/sync.js";
1
+ import { readSourceMeta, writeMeta, normalizeReferenceLinkList } from "../sync/sync.js";
2
2
 
3
3
  let savepointCounter = 0;
4
4
 
@@ -13,7 +13,7 @@ export function upsertSerializedReferenceLinks(existing, targetDocId, relation,
13
13
  }
14
14
 
15
15
  export function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
16
- const { meta } = readMeta(scenePath, syncDir, { writable: true });
16
+ const { sourceMeta: meta } = readSourceMeta(scenePath, syncDir, { writable: true });
17
17
  const existingExplicit = [
18
18
  ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
19
19
  ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
package/src/tools/sync.js CHANGED
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import matter from "gray-matter";
5
- import { syncAll, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, isManagedStructureProject } from "../sync/sync.js";
5
+ import { syncAll, writeMeta, readSourceMeta, indexSceneFile, normalizeSceneMetaForPath, isManagedStructureProject } from "../sync/sync.js";
6
6
  import { importScrivenerSync, validateProjectId } from "../sync/importer.js";
7
7
  import { runStructureDiagnostics } from "../structure/structure-diagnostics.js";
8
8
  import {
@@ -951,20 +951,21 @@ export function registerSyncTools(s, {
951
951
  try {
952
952
  const raw = fs.readFileSync(scene.file_path, "utf8");
953
953
  const { content: prose } = matter(raw);
954
- const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
954
+ const { sourceMeta: meta } = readSourceMeta(scene.file_path, SYNC_DIR, { writable: true });
955
955
 
956
956
  const inferredLogline = deriveLoglineFromProse(prose);
957
957
  const inferredCharacters = inferCharacterIdsFromProse(db, prose, scene.project_id);
958
958
 
959
- const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, {
959
+ const sourceUpdatedMeta = {
960
960
  ...meta,
961
961
  ...(inferredLogline ? { logline: inferredLogline } : {}),
962
962
  ...((inferredCharacters.length > 0 || (meta.characters?.length ?? 0) > 0)
963
963
  ? { characters: inferredCharacters.length > 0 ? inferredCharacters : meta.characters }
964
964
  : {}),
965
- }).meta;
965
+ };
966
+ const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, sourceUpdatedMeta).meta;
966
967
 
967
- writeMeta(scene.file_path, updatedMeta, { syncDir: SYNC_DIR });
968
+ writeMeta(scene.file_path, sourceUpdatedMeta, { syncDir: SYNC_DIR });
968
969
  indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose, {
969
970
  managedStructure: isManagedStructureProject(db, scene.project_id),
970
971
  });