@hanna84/mcp-writing 2.16.2 → 2.17.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,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
+ #### [v2.17.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.16.2...v2.17.0)
9
+
10
+ - feat(reference): persist explicit links to source metadata [`#161`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/161)
12
+
7
13
  #### [v2.16.2](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.16.1...v2.16.2)
9
15
 
16
+ > 1 May 2026
17
+
10
18
  - docs(prd): refresh reference-doc status and Phase 4C plan [`#160`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/160)
20
+ - Release 2.16.2 [`db02dcf`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/db02dcfe3d854ba2c79c072ab93f73d8ab69527c)
12
22
 
13
23
  #### [v2.16.1](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.16.0...v2.16.1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.16.2",
3
+ "version": "2.17.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",
package/src/sync/sync.js CHANGED
@@ -281,6 +281,50 @@ export function normalizeReferenceIdList(values) {
281
281
  )];
282
282
  }
283
283
 
284
+ function normalizeReferenceRelation(value, fallbackRelation) {
285
+ const normalized = String(value ?? fallbackRelation ?? "").trim().toLowerCase();
286
+ if (/^[a-z][a-z0-9_-]*$/.test(normalized)) return normalized;
287
+ return String(fallbackRelation ?? "related").trim().toLowerCase();
288
+ }
289
+
290
+ export function normalizeReferenceLinkList(values, { defaultRelation = "related" } = {}) {
291
+ const rawValues = Array.isArray(values)
292
+ ? values
293
+ : typeof values === "string"
294
+ ? values.split(",")
295
+ : [];
296
+
297
+ const links = [];
298
+ for (const value of rawValues) {
299
+ if (typeof value === "string") {
300
+ const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
301
+ for (const targetDocId of parts) {
302
+ links.push({ targetDocId, relation: normalizeReferenceRelation(defaultRelation, defaultRelation) });
303
+ }
304
+ continue;
305
+ }
306
+
307
+ if (!value || typeof value !== "object") continue;
308
+ const targetDocId = String(value.target_doc_id ?? value.doc_id ?? value.id ?? "").trim();
309
+ if (!targetDocId) continue;
310
+ links.push({
311
+ targetDocId,
312
+ relation: normalizeReferenceRelation(value.relation, defaultRelation),
313
+ });
314
+ }
315
+
316
+ const deduped = [];
317
+ const seen = new Set();
318
+ for (const link of links) {
319
+ const key = `${link.targetDocId}::${link.relation}`;
320
+ if (seen.has(key)) continue;
321
+ seen.add(key);
322
+ deduped.push(link);
323
+ }
324
+
325
+ return deduped;
326
+ }
327
+
284
328
  export function deriveReferenceSummary(meta = {}, content = "") {
285
329
  if (typeof meta.summary === "string" && meta.summary.trim()) return meta.summary.trim();
286
330
 
@@ -340,6 +384,59 @@ function indexReferenceLinksForSource(db, {
340
384
  }
341
385
  }
342
386
 
387
+ function indexExplicitReferenceLinksForSource(db, {
388
+ sourceKind,
389
+ sourceProjectId = "",
390
+ sourceId,
391
+ links,
392
+ defaultRelation,
393
+ }) {
394
+ db.prepare(`
395
+ DELETE FROM reference_links
396
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND origin = 'explicit'
397
+ `).run(sourceKind, sourceProjectId, sourceId);
398
+
399
+ const insertReferenceLink = db.prepare(`
400
+ INSERT OR IGNORE INTO reference_links (
401
+ source_kind, source_project_id, source_id, target_doc_id, relation, origin
402
+ ) VALUES (?, ?, ?, ?, ?, 'explicit')
403
+ `);
404
+
405
+ for (const link of links) {
406
+ if (sourceKind === "reference" && sourceId === link.targetDocId) continue;
407
+ insertReferenceLink.run(
408
+ sourceKind,
409
+ sourceProjectId,
410
+ sourceId,
411
+ link.targetDocId,
412
+ normalizeReferenceRelation(link.relation, defaultRelation)
413
+ );
414
+ }
415
+ }
416
+
417
+ function collectExplicitReferenceLinks(meta, fields, { defaultRelation }) {
418
+ const hasField = fields.some((field) => Object.prototype.hasOwnProperty.call(meta, field));
419
+ if (!hasField) {
420
+ return { hasField: false, links: [] };
421
+ }
422
+
423
+ const rawValues = [];
424
+ for (const field of fields) {
425
+ if (!Object.prototype.hasOwnProperty.call(meta, field)) continue;
426
+ const value = meta[field];
427
+ if (Array.isArray(value)) {
428
+ rawValues.push(...value);
429
+ } else if (value !== undefined && value !== null) {
430
+ rawValues.push(value);
431
+ }
432
+ }
433
+
434
+ return {
435
+ hasField: true,
436
+ links: normalizeReferenceLinkList(rawValues, { defaultRelation }),
437
+ };
438
+ }
439
+
343
440
  export function worldEntityKindForPath(syncDir, filePath) {
344
441
  const rel = path.relative(syncDir, filePath);
345
442
  if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) return "character";
@@ -643,6 +740,11 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
643
740
  const relatedReferenceIds = normalizeReferenceIdList(
644
741
  meta.related_reference_ids ?? meta.related_references ?? meta.related_docs ?? meta.related
645
742
  );
743
+ const explicitReferenceLinks = collectExplicitReferenceLinks(
744
+ meta,
745
+ ["reference_links", "related_reference_links", "explicit_reference_links"],
746
+ { defaultRelation: "related" }
747
+ );
646
748
 
647
749
  db.prepare(`
648
750
  INSERT INTO reference_docs (doc_id, project_id, universe_id, type, title, summary, file_path)
@@ -681,6 +783,16 @@ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
681
783
  tags.join(" ")
682
784
  );
683
785
 
786
+ if (explicitReferenceLinks.hasField) {
787
+ indexExplicitReferenceLinksForSource(db, {
788
+ sourceKind: "reference",
789
+ sourceProjectId: project_id ?? "",
790
+ sourceId: docId,
791
+ links: explicitReferenceLinks.links,
792
+ defaultRelation: "related",
793
+ });
794
+ }
795
+
684
796
  indexReferenceLinksForSource(db, {
685
797
  sourceKind: "reference",
686
798
  sourceProjectId: project_id ?? "",
@@ -798,6 +910,11 @@ function pruneMissingScenes(db, seenSceneKeys, syncDir) {
798
910
  export function indexSceneFile(db, syncDir, file, meta, prose) {
799
911
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
800
912
  const referenceIds = normalizeReferenceIdList(meta.reference_ids ?? meta.references);
913
+ const explicitSceneLinks = collectExplicitReferenceLinks(
914
+ meta,
915
+ ["reference_links", "explicit_reference_links"],
916
+ { defaultRelation: "informs" }
917
+ );
801
918
 
802
919
  if (universe_id) {
803
920
  db.prepare(`INSERT OR IGNORE INTO universes (universe_id, name) VALUES (?, ?)`).run(
@@ -922,6 +1039,16 @@ export function indexSceneFile(db, syncDir, file, meta, prose) {
922
1039
  keywordTokens,
923
1040
  );
924
1041
 
1042
+ if (explicitSceneLinks.hasField) {
1043
+ indexExplicitReferenceLinksForSource(db, {
1044
+ sourceKind: "scene",
1045
+ sourceProjectId: project_id ?? "",
1046
+ sourceId: meta.scene_id,
1047
+ links: explicitSceneLinks.links,
1048
+ defaultRelation: "informs",
1049
+ });
1050
+ }
1051
+
925
1052
  indexReferenceLinksForSource(db, {
926
1053
  sourceKind: "scene",
927
1054
  sourceProjectId: project_id ?? "",
@@ -1,9 +1,79 @@
1
1
  import { z } from "zod";
2
2
  import fs from "node:fs";
3
3
  import matter from "gray-matter";
4
- import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath } from "../sync/sync.js";
4
+ import { readMeta, writeMeta, indexSceneFile, normalizeSceneMetaForPath, normalizeReferenceLinkList } from "../sync/sync.js";
5
5
  import { validateProjectId, validateUniverseId } from "../sync/importer.js";
6
6
 
7
+ function upsertSerializedReferenceLinks(existing, targetDocId, relation, { defaultRelation }) {
8
+ const normalized = normalizeReferenceLinkList(existing ?? [], { defaultRelation });
9
+ const filtered = normalized.filter((entry) => entry.targetDocId !== targetDocId);
10
+ filtered.push({ targetDocId, relation });
11
+ return filtered.map((entry) => ({
12
+ target_doc_id: entry.targetDocId,
13
+ relation: entry.relation,
14
+ }));
15
+ }
16
+
17
+ function persistSceneReferenceLink({ scenePath, syncDir, targetDocId, relation }) {
18
+ const { meta } = readMeta(scenePath, syncDir, { writable: true });
19
+ const existingExplicit = [
20
+ ...(Array.isArray(meta.reference_links) ? meta.reference_links : meta.reference_links ? [meta.reference_links] : []),
21
+ ...(Array.isArray(meta.explicit_reference_links) ? meta.explicit_reference_links : meta.explicit_reference_links ? [meta.explicit_reference_links] : []),
22
+ ];
23
+ const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
24
+ defaultRelation: "informs",
25
+ });
26
+
27
+ const nextMeta = {
28
+ ...meta,
29
+ reference_links: nextReferenceLinks,
30
+ };
31
+ delete nextMeta.explicit_reference_links;
32
+
33
+ if (relation === "informs") {
34
+ const existingIds = Array.isArray(meta.reference_ids)
35
+ ? meta.reference_ids
36
+ : typeof meta.reference_ids === "string"
37
+ ? meta.reference_ids.split(",")
38
+ : [];
39
+ nextMeta.reference_ids = [...new Set([...existingIds.map((value) => String(value).trim()).filter(Boolean), targetDocId])];
40
+ }
41
+
42
+ writeMeta(scenePath, nextMeta);
43
+ }
44
+
45
+ function persistReferenceDocLink({ filePath, targetDocId, relation }) {
46
+ const raw = fs.readFileSync(filePath, "utf8");
47
+ const parsed = matter(raw);
48
+ const data = parsed.data ?? {};
49
+ const existingExplicit = [
50
+ ...(Array.isArray(data.reference_links) ? data.reference_links : data.reference_links ? [data.reference_links] : []),
51
+ ...(Array.isArray(data.related_reference_links) ? data.related_reference_links : data.related_reference_links ? [data.related_reference_links] : []),
52
+ ...(Array.isArray(data.explicit_reference_links) ? data.explicit_reference_links : data.explicit_reference_links ? [data.explicit_reference_links] : []),
53
+ ];
54
+ const nextReferenceLinks = upsertSerializedReferenceLinks(existingExplicit, targetDocId, relation, {
55
+ defaultRelation: "related",
56
+ });
57
+
58
+ const nextData = {
59
+ ...data,
60
+ reference_links: nextReferenceLinks,
61
+ };
62
+ delete nextData.related_reference_links;
63
+ delete nextData.explicit_reference_links;
64
+
65
+ if (relation === "related") {
66
+ const existingIds = Array.isArray(data.related_reference_ids)
67
+ ? data.related_reference_ids
68
+ : typeof data.related_reference_ids === "string"
69
+ ? data.related_reference_ids.split(",")
70
+ : [];
71
+ nextData.related_reference_ids = [...new Set([...existingIds.map((value) => String(value).trim()).filter(Boolean), targetDocId])];
72
+ }
73
+
74
+ fs.writeFileSync(filePath, matter.stringify(parsed.content, nextData), "utf8");
75
+ }
76
+
7
77
  export function registerMetadataTools(s, {
8
78
  db,
9
79
  SYNC_DIR,
@@ -205,10 +275,12 @@ export function registerMetadataTools(s, {
205
275
  }
206
276
 
207
277
  let resolvedSourceProjectId;
278
+ let sourceScenePath = null;
279
+ let sourceReferencePath = null;
208
280
  if (source_kind === "scene") {
209
281
  if (source_project_id) {
210
282
  const scene = db.prepare(`
211
- SELECT scene_id, project_id
283
+ SELECT scene_id, project_id, file_path
212
284
  FROM scenes
213
285
  WHERE scene_id = ? AND project_id = ?
214
286
  LIMIT 1
@@ -217,9 +289,10 @@ export function registerMetadataTools(s, {
217
289
  return errorResponse("NOT_FOUND", `Scene '${source_id}' not found in project '${source_project_id}'.`);
218
290
  }
219
291
  resolvedSourceProjectId = scene.project_id ?? "";
292
+ sourceScenePath = scene.file_path;
220
293
  } else {
221
294
  const matches = db.prepare(`
222
- SELECT scene_id, project_id
295
+ SELECT scene_id, project_id, file_path
223
296
  FROM scenes
224
297
  WHERE scene_id = ?
225
298
  ORDER BY project_id
@@ -235,10 +308,11 @@ export function registerMetadataTools(s, {
235
308
  );
236
309
  }
237
310
  resolvedSourceProjectId = matches[0].project_id ?? "";
311
+ sourceScenePath = matches[0].file_path;
238
312
  }
239
313
  } else {
240
314
  const sourceDoc = db.prepare(`
241
- SELECT doc_id, project_id
315
+ SELECT doc_id, project_id, file_path
242
316
  FROM reference_docs
243
317
  WHERE doc_id = ?
244
318
  LIMIT 1
@@ -267,6 +341,45 @@ export function registerMetadataTools(s, {
267
341
  }
268
342
  );
269
343
  }
344
+ sourceReferencePath = sourceDoc.file_path;
345
+ }
346
+
347
+ try {
348
+ if (source_kind === "scene") {
349
+ if (!sourceScenePath) {
350
+ return errorResponse("STALE_PATH", `Scene '${source_id}' has no indexed file path. Run sync() to refresh.`, {
351
+ source_id,
352
+ source_project_id: resolvedSourceProjectId,
353
+ });
354
+ }
355
+ persistSceneReferenceLink({
356
+ scenePath: sourceScenePath,
357
+ syncDir: SYNC_DIR,
358
+ targetDocId: target_doc_id,
359
+ relation: normalizedRelation,
360
+ });
361
+ } else {
362
+ if (!sourceReferencePath) {
363
+ return errorResponse("STALE_PATH", `Reference doc '${source_id}' has no indexed file path. Run sync() to refresh.`, {
364
+ source_id,
365
+ });
366
+ }
367
+ persistReferenceDocLink({
368
+ filePath: sourceReferencePath,
369
+ targetDocId: target_doc_id,
370
+ relation: normalizedRelation,
371
+ });
372
+ }
373
+ } catch (err) {
374
+ if (err?.code === "ENOENT") {
375
+ const indexedPath = source_kind === "scene" ? sourceScenePath : sourceReferencePath;
376
+ return errorResponse(
377
+ "STALE_PATH",
378
+ `Source file for ${source_kind} '${source_id}' not found at indexed path — run sync() to refresh.`,
379
+ { indexed_path: indexedPath }
380
+ );
381
+ }
382
+ return errorResponse("IO_ERROR", `Failed to persist link metadata: ${err.message}`);
270
383
  }
271
384
 
272
385
  try {