@hanna84/mcp-writing 2.12.21 → 2.13.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,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
+ #### [v2.13.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.12.22...v2.13.0)
9
+
10
+ - feat: add reference document search [`#146`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/146)
12
+
13
+ #### [v2.12.22](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v2.12.21...v2.12.22)
15
+
16
+ > 30 April 2026
17
+
18
+ - docs(prd): refine reference docs into reference graph model [`#143`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/143)
20
+ - Release 2.12.22 [`d363c63`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/d363c63ddcaf6de694f24d9ed66e2f703f439bf4)
22
+
7
23
  #### [v2.12.21](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v2.12.20...v2.12.21)
9
25
 
26
+ > 30 April 2026
27
+
10
28
  - docs: mark embeddings search as deferred backlog [`#142`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/142)
30
+ - Release 2.12.21 [`acafde5`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/acafde5e2b54a1459ad5286b1001f7c068cb035c)
12
32
 
13
33
  #### [v2.12.20](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v2.12.19...v2.12.20)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.12.21",
3
+ "version": "2.13.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/core/db.js CHANGED
@@ -106,7 +106,9 @@ export const SCHEMA = `
106
106
  doc_id TEXT NOT NULL PRIMARY KEY,
107
107
  project_id TEXT,
108
108
  universe_id TEXT,
109
+ type TEXT,
109
110
  title TEXT NOT NULL,
111
+ summary TEXT,
110
112
  file_path TEXT NOT NULL
111
113
  );
112
114
 
@@ -120,6 +122,10 @@ export const SCHEMA = `
120
122
  scene_id, project_id, logline, title, keywords
121
123
  );
122
124
 
125
+ CREATE VIRTUAL TABLE IF NOT EXISTS reference_docs_fts USING fts5(
126
+ doc_id, project_id, title, summary, tags
127
+ );
128
+
123
129
  CREATE TABLE IF NOT EXISTS schema_version (
124
130
  id INTEGER PRIMARY KEY CHECK (id = 1),
125
131
  version INTEGER NOT NULL
@@ -169,6 +175,27 @@ const MIGRATIONS = [
169
175
  db.exec(`ALTER TABLE scenes_fts_migrating RENAME TO scenes_fts;`);
170
176
  }
171
177
  },
178
+ // 3: add lightweight reference-doc metadata columns and FTS table
179
+ (db) => {
180
+ const referenceDocColumns = db.prepare(`PRAGMA table_info(reference_docs)`).all();
181
+ if (!referenceDocColumns.some(c => c.name === "type")) {
182
+ db.exec(`ALTER TABLE reference_docs ADD COLUMN type TEXT;`);
183
+ }
184
+ if (!referenceDocColumns.some(c => c.name === "summary")) {
185
+ db.exec(`ALTER TABLE reference_docs ADD COLUMN summary TEXT;`);
186
+ }
187
+
188
+ const ftsSql = db.prepare(`
189
+ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'reference_docs_fts'
190
+ `).get()?.sql;
191
+ if (typeof ftsSql !== "string") {
192
+ db.exec(`
193
+ CREATE VIRTUAL TABLE reference_docs_fts USING fts5(
194
+ doc_id, project_id, title, summary, tags
195
+ );
196
+ `);
197
+ }
198
+ },
172
199
  ];
173
200
 
174
201
  // The version every database should reach after openDb. Not the current DB value —
package/src/sync/sync.js CHANGED
@@ -170,6 +170,118 @@ export function isWorldFile(syncDir, filePath) {
170
170
  return rel.includes(`${path.sep}world${path.sep}`) || rel.includes("/world/");
171
171
  }
172
172
 
173
+ export function isReferenceFile(syncDir, filePath) {
174
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/").toLowerCase();
175
+ return rel.startsWith("world/reference/") || rel.includes("/world/reference/") || rel.startsWith("notes/") || rel.includes("/notes/");
176
+ }
177
+
178
+ export function inferReferenceDocType(syncDir, filePath) {
179
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/").toLowerCase();
180
+ if (rel.startsWith("world/reference/") || rel.includes("/world/reference/")) return "world";
181
+ if (rel.startsWith("notes/continuity/") || rel.includes("/notes/continuity/")) return "continuity";
182
+ if (rel.startsWith("notes/research/") || rel.includes("/notes/research/")) return "research";
183
+ if (rel.startsWith("notes/style/") || rel.includes("/notes/style/")) return "style";
184
+ return "reference";
185
+ }
186
+
187
+ function inferReferenceScopeFromSyncDir(syncDir) {
188
+ const parts = path.resolve(syncDir).split(path.sep).filter(Boolean);
189
+ const projectSlug = parts.at(-1);
190
+ const parent = parts.at(-2);
191
+ const universeId = parts.at(-2);
192
+ const universeProjectSlug = parts.at(-1);
193
+ const universeMarker = parts.at(-3);
194
+
195
+ if (parent === "projects" && projectSlug) {
196
+ return { universe_id: null, project_id: projectSlug };
197
+ }
198
+
199
+ if (parent === "universes" && projectSlug) {
200
+ return { universe_id: projectSlug, project_id: null };
201
+ }
202
+
203
+ if (universeMarker === "universes" && universeId && universeProjectSlug) {
204
+ return { universe_id: universeId, project_id: `${universeId}/${universeProjectSlug}` };
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ function inferReferenceProjectAndUniverse(syncDir, filePath) {
211
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/").toLowerCase();
212
+ const scoped = inferReferenceScopeFromSyncDir(syncDir);
213
+ if (
214
+ scoped
215
+ && (rel.startsWith("world/reference/") || rel.startsWith("notes/"))
216
+ ) {
217
+ return scoped;
218
+ }
219
+
220
+ return inferProjectAndUniverse(syncDir, filePath);
221
+ }
222
+
223
+ function slugifyReferencePart(value) {
224
+ return String(value ?? "")
225
+ .toLowerCase()
226
+ .replace(/\.(md|txt)$/i, "")
227
+ .replace(/[^a-z0-9]+/g, "-")
228
+ .replace(/^-+|-+$/g, "");
229
+ }
230
+
231
+ export function deriveReferenceDocId(syncDir, filePath, meta = {}) {
232
+ if (typeof meta.doc_id === "string" && meta.doc_id.trim()) return meta.doc_id.trim();
233
+
234
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
235
+ const slug = rel
236
+ .split("/")
237
+ .map(slugifyReferencePart)
238
+ .filter(Boolean)
239
+ .join("-");
240
+ return `ref-${slug}`;
241
+ }
242
+
243
+ export function deriveReferenceTitle(filePath, meta = {}, content = "") {
244
+ if (typeof meta.title === "string" && meta.title.trim()) return meta.title.trim();
245
+
246
+ const heading = content.match(/^\s*#\s+(.+?)\s*$/m)?.[1]?.trim();
247
+ if (heading) return heading;
248
+
249
+ const base = path.basename(filePath, path.extname(filePath));
250
+ return base
251
+ .replace(/[-_]+/g, " ")
252
+ .replace(/\s+/g, " ")
253
+ .trim();
254
+ }
255
+
256
+ export function normalizeReferenceTags(tags) {
257
+ const values = Array.isArray(tags)
258
+ ? tags
259
+ : typeof tags === "string"
260
+ ? tags.split(",")
261
+ : [];
262
+
263
+ return [...new Set(
264
+ values
265
+ .map(tag => String(tag).trim())
266
+ .filter(Boolean)
267
+ )];
268
+ }
269
+
270
+ export function deriveReferenceSummary(meta = {}, content = "") {
271
+ if (typeof meta.summary === "string" && meta.summary.trim()) return meta.summary.trim();
272
+
273
+ const body = content
274
+ .split("\n")
275
+ .map(line => line.trim())
276
+ .filter(line => line && !line.startsWith("#"))
277
+ .join(" ")
278
+ .replace(/\s+/g, " ")
279
+ .trim();
280
+
281
+ if (!body) return null;
282
+ return body.length <= 240 ? body : `${body.slice(0, 237).trimEnd()}...`;
283
+ }
284
+
173
285
  export function worldEntityKindForPath(syncDir, filePath) {
174
286
  const rel = path.relative(syncDir, filePath);
175
287
  if (rel.includes(`${path.sep}characters${path.sep}`) || rel.includes("/characters/")) return "character";
@@ -463,6 +575,82 @@ export function indexWorldFile(db, syncDir, file, meta) {
463
575
  }
464
576
  }
465
577
 
578
+ export function indexReferenceFile(db, syncDir, file, meta = {}, content = "") {
579
+ const { universe_id, project_id } = inferReferenceProjectAndUniverse(syncDir, file);
580
+ const docId = deriveReferenceDocId(syncDir, file, meta);
581
+ const type = inferReferenceDocType(syncDir, file);
582
+ const title = deriveReferenceTitle(file, meta, content);
583
+ const summary = deriveReferenceSummary(meta, content);
584
+ const tags = normalizeReferenceTags(meta.tags);
585
+
586
+ db.prepare(`
587
+ INSERT INTO reference_docs (doc_id, project_id, universe_id, type, title, summary, file_path)
588
+ VALUES (?, ?, ?, ?, ?, ?, ?)
589
+ ON CONFLICT (doc_id) DO UPDATE SET
590
+ project_id = excluded.project_id,
591
+ universe_id = excluded.universe_id,
592
+ type = excluded.type,
593
+ title = excluded.title,
594
+ summary = excluded.summary,
595
+ file_path = excluded.file_path
596
+ `).run(
597
+ docId,
598
+ project_id ?? null,
599
+ universe_id ?? null,
600
+ type,
601
+ title,
602
+ summary ?? null,
603
+ file
604
+ );
605
+
606
+ db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(docId);
607
+ for (const tag of tags) {
608
+ db.prepare(`INSERT OR IGNORE INTO reference_doc_tags (doc_id, tag) VALUES (?, ?)`).run(docId, tag);
609
+ }
610
+
611
+ db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(docId);
612
+ db.prepare(`
613
+ INSERT INTO reference_docs_fts (doc_id, project_id, title, summary, tags)
614
+ VALUES (?, ?, ?, ?, ?)
615
+ `).run(
616
+ docId,
617
+ project_id ?? "",
618
+ title,
619
+ summary ?? "",
620
+ tags.join(" ")
621
+ );
622
+
623
+ return docId;
624
+ }
625
+
626
+ function pruneMissingReferenceDocs(db, seenDocIds) {
627
+ const rows = db.prepare(`SELECT doc_id FROM reference_docs`).all();
628
+ for (const row of rows) {
629
+ if (seenDocIds.has(row.doc_id)) continue;
630
+ db.prepare(`DELETE FROM reference_doc_tags WHERE doc_id = ?`).run(row.doc_id);
631
+ db.prepare(`DELETE FROM reference_docs_fts WHERE doc_id = ?`).run(row.doc_id);
632
+ db.prepare(`DELETE FROM reference_docs WHERE doc_id = ?`).run(row.doc_id);
633
+ }
634
+ }
635
+
636
+ function canPruneReferenceDocs(syncDir) {
637
+ const resolvedSyncDir = path.resolve(syncDir);
638
+ const scopedRoot = inferReferenceScopeFromSyncDir(resolvedSyncDir);
639
+ if (scopedRoot) return true;
640
+
641
+ // Flat project roots and broad workspace roots can safely prune because
642
+ // they can observe the full set of reference docs in their scope.
643
+ const hasBroadRootChild = ["projects", "universes", "scenes"].some((name) => {
644
+ try {
645
+ return fs.statSync(path.join(resolvedSyncDir, name)).isDirectory();
646
+ } catch {
647
+ return false;
648
+ }
649
+ });
650
+
651
+ return hasBroadRootChild;
652
+ }
653
+
466
654
  export function indexSceneFile(db, syncDir, file, meta, prose) {
467
655
  const { universe_id, project_id } = inferProjectAndUniverse(syncDir, file);
468
656
 
@@ -639,6 +827,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
639
827
  let sidecarsMigrated = 0;
640
828
  const seenSceneIds = new Map(); // scene_id+project_id → file path, for duplicate detection
641
829
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
830
+ const indexedReferenceDocIds = new Set();
642
831
  const warnings = [];
643
832
 
644
833
  const scanFiles = [];
@@ -650,9 +839,20 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
650
839
  scanFiles.push(file);
651
840
  }
652
841
 
653
- // --- Pass 1: world files (characters/places must be indexed before scenes
654
- // so that character name ID resolution in scene_characters works) ---
842
+ // --- Pass 1: world files and reference docs (characters/places must be indexed
843
+ // before scenes so that character name -> ID resolution in scene_characters works) ---
655
844
  for (const file of scanFiles) {
845
+ if (isReferenceFile(syncDir, file)) {
846
+ try {
847
+ const { data, content } = parseFile(file);
848
+ const docId = indexReferenceFile(db, syncDir, file, data, content);
849
+ indexedReferenceDocIds.add(docId);
850
+ } catch (err) {
851
+ process.stderr.write(`[mcp-writing] Failed to index ${file}: ${err.message}\n`);
852
+ }
853
+ continue;
854
+ }
855
+
656
856
  if (!isWorldFile(syncDir, file)) continue;
657
857
  try {
658
858
  const { meta } = readMeta(file, syncDir, { writable });
@@ -667,9 +867,13 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
667
867
  }
668
868
  }
669
869
 
870
+ if (canPruneReferenceDocs(syncDir)) {
871
+ pruneMissingReferenceDocs(db, indexedReferenceDocIds);
872
+ }
873
+
670
874
  // --- Pass 2: scene files ---
671
875
  for (const file of scanFiles) {
672
- if (isWorldFile(syncDir, file)) continue;
876
+ if (isWorldFile(syncDir, file) || isReferenceFile(syncDir, file)) continue;
673
877
  try {
674
878
  const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
675
879
  if (sidecarGenerated) sidecarsMigrated++;
@@ -424,6 +424,67 @@ export function registerSearchTools(s, {
424
424
  }
425
425
  );
426
426
 
427
+ // ---- search_reference ----------------------------------------------------
428
+ s.tool(
429
+ "search_reference",
430
+ "Full-text search across indexed reference document titles, summaries, and tags. Use this to discover world-building notes, continuity references, research docs, and other reference material without loading full file contents.",
431
+ {
432
+ query: z.string().describe("Search terms (e.g. 'vampirism' or 'blood replacement'). FTS5 syntax supported."),
433
+ type: z.string().optional().describe("Optional reference type filter (for example: 'world', 'continuity', 'research', 'style')."),
434
+ tag: z.string().optional().describe("Optional exact tag filter."),
435
+ },
436
+ async ({ query, type, tag }) => {
437
+ let matchRows;
438
+ try {
439
+ matchRows = db.prepare(`
440
+ SELECT doc_id, rank
441
+ FROM reference_docs_fts
442
+ WHERE reference_docs_fts MATCH ?
443
+ ORDER BY rank
444
+ `).all(query);
445
+ } catch (err) {
446
+ return errorResponse("INVALID_QUERY", "Invalid reference search query syntax. Use plain keywords or quoted phrases.", { detail: err.message });
447
+ }
448
+
449
+ if (matchRows.length === 0) {
450
+ return errorResponse("NO_RESULTS", "No reference documents matched the search query.");
451
+ }
452
+
453
+ const rows = [];
454
+ const docStmt = db.prepare(`
455
+ SELECT doc_id, project_id, universe_id, type, title, summary, file_path
456
+ FROM reference_docs
457
+ WHERE doc_id = ?
458
+ `);
459
+ const tagsStmt = db.prepare(`
460
+ SELECT tag
461
+ FROM reference_doc_tags
462
+ WHERE doc_id = ?
463
+ ORDER BY tag
464
+ `);
465
+
466
+ for (const match of matchRows) {
467
+ const doc = docStmt.get(match.doc_id);
468
+ if (!doc) continue;
469
+
470
+ const tags = tagsStmt.all(match.doc_id).map(row => row.tag);
471
+ if (type && doc.type !== type) continue;
472
+ if (tag && !tags.includes(tag)) continue;
473
+
474
+ rows.push({
475
+ ...doc,
476
+ tags,
477
+ });
478
+ }
479
+
480
+ if (rows.length === 0) {
481
+ return errorResponse("NO_RESULTS", "No reference documents matched the provided filters.");
482
+ }
483
+
484
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
485
+ }
486
+ );
487
+
427
488
  // ---- list_threads --------------------------------------------------------
428
489
  s.tool(
429
490
  "list_threads",