@hanna84/mcp-writing 2.15.0 → 2.16.1
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 +20 -0
- package/package.json +1 -1
- package/src/core/db.js +8 -0
- package/src/sync/sync.js +25 -5
- package/src/tools/metadata.js +135 -0
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.16.1](https://github.com/hannasdev/mcp-writing.git
|
|
8
|
+
/compare/v2.16.0...v2.16.1)
|
|
9
|
+
|
|
10
|
+
- docs(prd): define Option C rollout decision [`#159`](https://github.com/hannasdev/mcp-writing.git
|
|
11
|
+
/pull/159)
|
|
12
|
+
|
|
13
|
+
#### [v2.16.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v2.15.0...v2.16.0)
|
|
15
|
+
|
|
16
|
+
> 30 April 2026
|
|
17
|
+
|
|
18
|
+
- feat: add upsert_reference_link write tool [`#150`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/150)
|
|
20
|
+
- Release 2.16.0 [`222295d`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/222295d6fa812ac160f6ed4ea047f9ad4846b931)
|
|
22
|
+
|
|
7
23
|
#### [v2.15.0](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v2.14.0...v2.15.0)
|
|
9
25
|
|
|
26
|
+
> 30 April 2026
|
|
27
|
+
|
|
10
28
|
- feat(reference): add reference link query tools [`#148`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/148)
|
|
30
|
+
- Release 2.15.0 [`1b9ac11`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/1b9ac11157f8385e85a0c6c86e30109e9b9b2220)
|
|
12
32
|
|
|
13
33
|
#### [v2.14.0](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v2.13.0...v2.14.0)
|
package/package.json
CHANGED
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
|
-
|
|
314
|
-
|
|
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(
|
|
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
|
|
package/src/tools/metadata.js
CHANGED
|
@@ -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",
|