@hanna84/mcp-writing 2.15.0 → 2.16.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.16.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.15.0...v2.16.0)
9
+
10
+ - feat: add upsert_reference_link write tool [`#150`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/150)
12
+
7
13
  #### [v2.15.0](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.14.0...v2.15.0)
9
15
 
16
+ > 30 April 2026
17
+
10
18
  - feat(reference): add reference link query tools [`#148`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/148)
20
+ - Release 2.15.0 [`1b9ac11`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/1b9ac11157f8385e85a0c6c86e30109e9b9b2220)
12
22
 
13
23
  #### [v2.14.0](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.13.0...v2.14.0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.15.0",
3
+ "version": "2.16.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
@@ -124,6 +124,7 @@ export const SCHEMA = `
124
124
  source_id TEXT NOT NULL,
125
125
  target_doc_id TEXT NOT NULL,
126
126
  relation TEXT NOT NULL,
127
+ origin TEXT NOT NULL DEFAULT 'inferred',
127
128
  PRIMARY KEY (source_kind, source_project_id, source_id, target_doc_id, relation)
128
129
  );
129
130
 
@@ -274,6 +275,13 @@ const MIGRATIONS = [
274
275
  ON reference_links(target_doc_id);
275
276
  `);
276
277
  },
278
+ // 6: add origin marker to reference_links so sync can preserve explicit tool-authored links
279
+ (db) => {
280
+ const columns = db.prepare(`PRAGMA table_info(reference_links)`).all();
281
+ if (!columns.some(c => c.name === "origin")) {
282
+ db.exec(`ALTER TABLE reference_links ADD COLUMN origin TEXT NOT NULL DEFAULT 'inferred';`);
283
+ }
284
+ },
277
285
  ];
278
286
 
279
287
  // The version every database should reach after openDb. Not the current DB value —
package/src/sync/sync.js CHANGED
@@ -305,18 +305,38 @@ function indexReferenceLinksForSource(db, {
305
305
  }) {
306
306
  db.prepare(`
307
307
  DELETE FROM reference_links
308
- WHERE source_kind = ? AND source_project_id = ? AND source_id = ?
308
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND origin = 'inferred'
309
309
  `).run(sourceKind, sourceProjectId, sourceId);
310
310
 
311
311
  const insertReferenceLink = db.prepare(`
312
- INSERT OR IGNORE INTO reference_links
313
- (source_kind, source_project_id, source_id, target_doc_id, relation)
314
- VALUES (?, ?, ?, ?, ?)
312
+ INSERT OR IGNORE INTO reference_links (
313
+ source_kind, source_project_id, source_id, target_doc_id, relation, origin
314
+ )
315
+ SELECT ?, ?, ?, ?, ?, 'inferred'
316
+ WHERE NOT EXISTS (
317
+ SELECT 1
318
+ FROM reference_links existing
319
+ WHERE existing.source_kind = ?
320
+ AND existing.source_project_id = ?
321
+ AND existing.source_id = ?
322
+ AND existing.target_doc_id = ?
323
+ AND existing.origin = 'explicit'
324
+ )
315
325
  `);
316
326
 
317
327
  for (const targetDocId of targetDocIds) {
318
328
  if (sourceKind === "reference" && sourceId === targetDocId) continue;
319
- insertReferenceLink.run(sourceKind, sourceProjectId, sourceId, targetDocId, relation);
329
+ insertReferenceLink.run(
330
+ sourceKind,
331
+ sourceProjectId,
332
+ sourceId,
333
+ targetDocId,
334
+ relation,
335
+ sourceKind,
336
+ sourceProjectId,
337
+ sourceId,
338
+ targetDocId
339
+ );
320
340
  }
321
341
  }
322
342
 
@@ -170,6 +170,141 @@ export function registerMetadataTools(s, {
170
170
  }
171
171
  );
172
172
 
173
+ // ---- upsert_reference_link -----------------------------------------------
174
+ s.tool(
175
+ "upsert_reference_link",
176
+ "Create or update an explicit reference link from a scene or reference doc to a target reference doc. If a link already exists between the same source and target, this updates the relation. Only available when the sync dir is writable.",
177
+ {
178
+ source_kind: z.enum(["scene", "reference"]).describe("Link source kind."),
179
+ source_id: z.string().describe("Source scene_id or reference doc_id."),
180
+ source_project_id: z.string().optional().describe("Optional project scope for the source. For scene sources, use this to disambiguate an ambiguous scene_id across projects. For reference sources, when provided, it is treated as an ownership check and must match the source reference doc's project."),
181
+ target_doc_id: z.string().describe("Target reference doc_id."),
182
+ relation: z.string().describe("Relationship label (for example: 'informs', 'related', 'history_of'). The value is trimmed and lowercased before validation."),
183
+ },
184
+ async ({ source_kind, source_id, source_project_id, target_doc_id, relation }) => {
185
+ if (!SYNC_DIR_WRITABLE) {
186
+ return errorResponse("READ_ONLY", "Cannot write reference links: sync dir is read-only.");
187
+ }
188
+
189
+ const normalizedRelation = relation.trim().toLowerCase();
190
+ if (!/^[a-z][a-z0-9_-]*$/.test(normalizedRelation)) {
191
+ return errorResponse(
192
+ "VALIDATION_ERROR",
193
+ "Relation is normalized to lowercase and must match [a-z][a-z0-9_-]* after normalization (for example: 'informs' or 'history_of').",
194
+ { relation }
195
+ );
196
+ }
197
+
198
+ const targetDoc = db.prepare(`
199
+ SELECT doc_id
200
+ FROM reference_docs
201
+ WHERE doc_id = ?
202
+ `).get(target_doc_id);
203
+ if (!targetDoc) {
204
+ return errorResponse("NOT_FOUND", `Target reference doc '${target_doc_id}' not found.`);
205
+ }
206
+
207
+ let resolvedSourceProjectId;
208
+ if (source_kind === "scene") {
209
+ if (source_project_id) {
210
+ const scene = db.prepare(`
211
+ SELECT scene_id, project_id
212
+ FROM scenes
213
+ WHERE scene_id = ? AND project_id = ?
214
+ LIMIT 1
215
+ `).get(source_id, source_project_id);
216
+ if (!scene) {
217
+ return errorResponse("NOT_FOUND", `Scene '${source_id}' not found in project '${source_project_id}'.`);
218
+ }
219
+ resolvedSourceProjectId = scene.project_id ?? "";
220
+ } else {
221
+ const matches = db.prepare(`
222
+ SELECT scene_id, project_id
223
+ FROM scenes
224
+ WHERE scene_id = ?
225
+ ORDER BY project_id
226
+ `).all(source_id);
227
+ if (matches.length === 0) {
228
+ return errorResponse("NOT_FOUND", `Scene '${source_id}' not found.`);
229
+ }
230
+ if (matches.length > 1) {
231
+ return errorResponse(
232
+ "CONFLICT",
233
+ `Scene ID '${source_id}' exists in multiple projects. Provide source_project_id to disambiguate.`,
234
+ { source_id, project_ids: matches.map(row => row.project_id) }
235
+ );
236
+ }
237
+ resolvedSourceProjectId = matches[0].project_id ?? "";
238
+ }
239
+ } else {
240
+ const sourceDoc = db.prepare(`
241
+ SELECT doc_id, project_id
242
+ FROM reference_docs
243
+ WHERE doc_id = ?
244
+ LIMIT 1
245
+ `).get(source_id);
246
+ if (!sourceDoc) {
247
+ return errorResponse("NOT_FOUND", `Source reference doc '${source_id}' not found.`);
248
+ }
249
+ if (source_id === target_doc_id) {
250
+ return errorResponse("VALIDATION_ERROR", "Self-links are not allowed for reference sources.");
251
+ }
252
+ resolvedSourceProjectId = sourceDoc.project_id ?? "";
253
+ if ((source_project_id ?? "") !== "" && source_project_id !== resolvedSourceProjectId) {
254
+ const resolvedSourceProjectLabel = resolvedSourceProjectId === ""
255
+ ? "unscoped/no project"
256
+ : `project '${resolvedSourceProjectId}'`;
257
+ const requestedSourceProjectLabel = source_project_id === ""
258
+ ? "unscoped/no project"
259
+ : `project '${source_project_id}'`;
260
+ return errorResponse(
261
+ "CONFLICT",
262
+ `Source reference doc '${source_id}' belongs to ${resolvedSourceProjectLabel}, not ${requestedSourceProjectLabel}.`,
263
+ {
264
+ source_id,
265
+ source_project_id,
266
+ resolved_source_project_id: resolvedSourceProjectId,
267
+ }
268
+ );
269
+ }
270
+ }
271
+
272
+ try {
273
+ db.exec("BEGIN");
274
+ db.prepare(`
275
+ DELETE FROM reference_links
276
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ?
277
+ `).run(source_kind, resolvedSourceProjectId, source_id, target_doc_id);
278
+
279
+ db.prepare(`
280
+ INSERT INTO reference_links (
281
+ source_kind, source_project_id, source_id, target_doc_id, relation, origin
282
+ ) VALUES (?, ?, ?, ?, ?, 'explicit')
283
+ `).run(source_kind, resolvedSourceProjectId, source_id, target_doc_id, normalizedRelation);
284
+ db.exec("COMMIT");
285
+ } catch (err) {
286
+ try {
287
+ db.exec("ROLLBACK");
288
+ } catch (rollbackErr) {
289
+ void rollbackErr;
290
+ }
291
+ throw err;
292
+ }
293
+
294
+ const link = db.prepare(`
295
+ SELECT source_kind, source_project_id, source_id, target_doc_id, relation, origin
296
+ FROM reference_links
297
+ WHERE source_kind = ? AND source_project_id = ? AND source_id = ? AND target_doc_id = ? AND relation = ?
298
+ `).get(source_kind, resolvedSourceProjectId, source_id, target_doc_id, normalizedRelation);
299
+
300
+ return jsonResponse({
301
+ ok: true,
302
+ action: "upserted",
303
+ link,
304
+ });
305
+ }
306
+ );
307
+
173
308
  // ---- update_scene_metadata -----------------------------------------------
174
309
  s.tool(
175
310
  "update_scene_metadata",