@hanna84/mcp-writing 3.25.0 → 3.26.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,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.26.0](https://github.com/hannasdev/mcp-writing/compare/v3.25.0...v3.26.0)
8
+
9
+ - feat: add canonical target resolver [`#236`](https://github.com/hannasdev/mcp-writing/pull/236)
10
+
7
11
  #### [v3.25.0](https://github.com/hannasdev/mcp-writing/compare/v3.24.1...v3.25.0)
8
12
 
13
+ > 6 June 2026
14
+
9
15
  - feat: clarify M1 workflow and relationship responses [`#235`](https://github.com/hannasdev/mcp-writing/pull/235)
16
+ - Release 3.25.0 [`45b7c47`](https://github.com/hannasdev/mcp-writing/commit/45b7c4782ca6d6e54d66a49bcee5ae40d3f1cd69)
10
17
 
11
18
  #### [v3.24.1](https://github.com/hannasdev/mcp-writing/compare/v3.24.0...v3.24.1)
12
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "3.25.0",
3
+ "version": "3.26.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",
@@ -0,0 +1,405 @@
1
+ const DEFAULT_CANDIDATE_LIMIT = 5;
2
+ const HARD_CANDIDATE_LIMIT = 10;
3
+
4
+ const MATCH_GROUP_ORDER = new Map([
5
+ ["exact_id", 0],
6
+ ["case_insensitive_id", 1],
7
+ ["case_insensitive_name", 2],
8
+ ["case_insensitive_title", 2],
9
+ ["near_match_suggestion", 3],
10
+ ]);
11
+
12
+ function normalizeValue(value) {
13
+ return String(value ?? "").trim().toLowerCase();
14
+ }
15
+
16
+ function clampCandidateLimit(candidateLimit) {
17
+ if (!Number.isInteger(candidateLimit) || candidateLimit <= 0) {
18
+ return DEFAULT_CANDIDATE_LIMIT;
19
+ }
20
+ return Math.min(candidateLimit, HARD_CANDIDATE_LIMIT);
21
+ }
22
+
23
+ function getProjectUniverseId(db, projectId) {
24
+ return db.prepare(`SELECT universe_id FROM projects WHERE project_id = ?`).get(projectId)?.universe_id ?? null;
25
+ }
26
+
27
+ function levenshteinDistance(left, right) {
28
+ if (left === right) return 0;
29
+ if (left.length === 0) return right.length;
30
+ if (right.length === 0) return left.length;
31
+
32
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
33
+ const current = Array.from({ length: right.length + 1 }, () => 0);
34
+
35
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
36
+ current[0] = leftIndex;
37
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
38
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
39
+ current[rightIndex] = Math.min(
40
+ current[rightIndex - 1] + 1,
41
+ previous[rightIndex] + 1,
42
+ previous[rightIndex - 1] + cost
43
+ );
44
+ }
45
+ for (let index = 0; index < previous.length; index += 1) {
46
+ previous[index] = current[index];
47
+ }
48
+ }
49
+
50
+ return previous[right.length];
51
+ }
52
+
53
+ function isNearMatch(input, value) {
54
+ const normalizedInput = normalizeValue(input);
55
+ const normalizedValue = normalizeValue(value);
56
+ if (!normalizedInput || !normalizedValue) return false;
57
+ if (normalizedInput.length < 3 || normalizedValue.length < 3) return false;
58
+ if (normalizedValue.includes(normalizedInput) || normalizedInput.includes(normalizedValue)) return true;
59
+
60
+ const distance = levenshteinDistance(normalizedInput, normalizedValue);
61
+ const threshold = Math.max(1, Math.floor(Math.max(normalizedInput.length, normalizedValue.length) / 4));
62
+ return distance <= threshold;
63
+ }
64
+
65
+ function sortCandidates(candidates) {
66
+ return [...candidates].sort((left, right) => {
67
+ const groupDelta = (MATCH_GROUP_ORDER.get(left.match_type) ?? 99) - (MATCH_GROUP_ORDER.get(right.match_type) ?? 99);
68
+ if (groupDelta !== 0) return groupDelta;
69
+
70
+ const labelDelta = normalizeValue(left.label).localeCompare(normalizeValue(right.label));
71
+ if (labelDelta !== 0) return labelDelta;
72
+
73
+ return left.id.localeCompare(right.id);
74
+ });
75
+ }
76
+
77
+ function capCandidates(candidates, candidateLimit) {
78
+ return sortCandidates(candidates).slice(0, clampCandidateLimit(candidateLimit));
79
+ }
80
+
81
+ function formatSceneCandidate(row, { matchedField, matchType }) {
82
+ return {
83
+ target_kind: "scene",
84
+ id: row.scene_id,
85
+ label: row.title || row.scene_id,
86
+ matched_field: matchedField,
87
+ match_type: matchType,
88
+ project_id: row.project_id,
89
+ context: {
90
+ project_id: row.project_id,
91
+ title: row.title ?? null,
92
+ chapter_id: row.chapter_id ?? null,
93
+ chapter_title: row.chapter_title ?? null,
94
+ timeline_position: row.timeline_position ?? null,
95
+ },
96
+ };
97
+ }
98
+
99
+ function formatCharacterCandidate(row, { matchedField, matchType }) {
100
+ return {
101
+ target_kind: "character",
102
+ id: row.character_id,
103
+ label: row.name || row.character_id,
104
+ matched_field: matchedField,
105
+ match_type: matchType,
106
+ project_id: row.project_id ?? null,
107
+ universe_id: row.universe_id ?? null,
108
+ context: {
109
+ role: row.role ?? null,
110
+ first_appearance: row.first_appearance ?? null,
111
+ },
112
+ };
113
+ }
114
+
115
+ function formatPlaceCandidate(row, { matchedField, matchType }) {
116
+ return {
117
+ target_kind: "place",
118
+ id: row.place_id,
119
+ label: row.name || row.place_id,
120
+ matched_field: matchedField,
121
+ match_type: matchType,
122
+ project_id: row.project_id ?? null,
123
+ universe_id: row.universe_id ?? null,
124
+ };
125
+ }
126
+
127
+ function buildResolvedFrom(argumentName, input, candidate) {
128
+ if (candidate.match_type === "exact_id") return undefined;
129
+ return {
130
+ [argumentName]: {
131
+ input,
132
+ matched_field: candidate.matched_field,
133
+ match_type: candidate.match_type,
134
+ id: candidate.id,
135
+ },
136
+ };
137
+ }
138
+
139
+ function nextStepForTargetKind(targetKind) {
140
+ if (targetKind === "scene") {
141
+ return "Use find_scenes with project_id to identify the canonical scene_id, then retry with the stable ID.";
142
+ }
143
+ if (targetKind === "character") {
144
+ return "Use list_characters to inspect candidate character_id values for this project or universe, then retry with the stable ID.";
145
+ }
146
+ return "Use list_places to inspect candidate place_id values for this project or universe, then retry with the stable ID.";
147
+ }
148
+
149
+ function buildResolutionFailure({ targetKind, input, projectId, universeId, candidates, candidateLimit }) {
150
+ const cappedCandidates = capCandidates(candidates, candidateLimit);
151
+ const details = {
152
+ lookup_kind: targetKind,
153
+ target_kind: targetKind,
154
+ input,
155
+ project_id: projectId,
156
+ ...(universeId !== undefined ? { universe_id: universeId } : {}),
157
+ candidate_matches: cappedCandidates,
158
+ next_step: nextStepForTargetKind(targetKind),
159
+ };
160
+
161
+ if (candidates.some(candidate => candidate.match_type !== "near_match_suggestion")) {
162
+ return {
163
+ ok: false,
164
+ error: {
165
+ code: "AMBIGUOUS_TARGET",
166
+ message: `${targetKind} '${input}' resolves to multiple canonical targets. Use a stable canonical ID.`,
167
+ details,
168
+ },
169
+ };
170
+ }
171
+
172
+ return {
173
+ ok: false,
174
+ error: {
175
+ code: "NOT_FOUND",
176
+ message: `${targetKind} '${input}' was not found in the provided scope.`,
177
+ details,
178
+ },
179
+ };
180
+ }
181
+
182
+ function buildSuccess({ targetKind, input, argumentName, row, candidate, idField, rowField }) {
183
+ const resolvedFrom = buildResolvedFrom(argumentName, input, candidate);
184
+ return {
185
+ ok: true,
186
+ target_kind: targetKind,
187
+ id: candidate.id,
188
+ [idField]: candidate.id,
189
+ [rowField]: row,
190
+ canonical: candidate.match_type === "exact_id",
191
+ match: {
192
+ matched_field: candidate.matched_field,
193
+ match_type: candidate.match_type,
194
+ id: candidate.id,
195
+ },
196
+ ...(resolvedFrom ? { resolved_from: resolvedFrom } : {}),
197
+ };
198
+ }
199
+
200
+ function resolveFromRows({
201
+ rows,
202
+ input,
203
+ targetKind,
204
+ idField,
205
+ nameField,
206
+ nameMatchType,
207
+ argumentName,
208
+ projectId,
209
+ universeId,
210
+ candidateLimit,
211
+ formatCandidate,
212
+ }) {
213
+ const normalizedInput = normalizeValue(input);
214
+ const exactIdRows = rows.filter(row => row[idField] === input);
215
+ if (exactIdRows.length === 1) {
216
+ const candidate = formatCandidate(exactIdRows[0], { matchedField: idField, matchType: "exact_id" });
217
+ return buildSuccess({
218
+ targetKind,
219
+ input,
220
+ argumentName,
221
+ row: exactIdRows[0],
222
+ candidate,
223
+ idField,
224
+ rowField: targetKind,
225
+ });
226
+ }
227
+
228
+ const caseInsensitiveIdRows = rows.filter(row => normalizeValue(row[idField]) === normalizedInput);
229
+ if (caseInsensitiveIdRows.length === 1) {
230
+ const candidate = formatCandidate(caseInsensitiveIdRows[0], { matchedField: idField, matchType: "case_insensitive_id" });
231
+ return buildSuccess({
232
+ targetKind,
233
+ input,
234
+ argumentName,
235
+ row: caseInsensitiveIdRows[0],
236
+ candidate,
237
+ idField,
238
+ rowField: targetKind,
239
+ });
240
+ }
241
+ if (caseInsensitiveIdRows.length > 1) {
242
+ return buildResolutionFailure({
243
+ targetKind,
244
+ input,
245
+ projectId,
246
+ universeId,
247
+ candidateLimit,
248
+ candidates: caseInsensitiveIdRows.map(row => formatCandidate(row, {
249
+ matchedField: idField,
250
+ matchType: "case_insensitive_id",
251
+ })),
252
+ });
253
+ }
254
+
255
+ const caseInsensitiveNameRows = rows.filter(row => normalizeValue(row[nameField]) === normalizedInput);
256
+ if (caseInsensitiveNameRows.length === 1) {
257
+ const candidate = formatCandidate(caseInsensitiveNameRows[0], { matchedField: nameField, matchType: nameMatchType });
258
+ return buildSuccess({
259
+ targetKind,
260
+ input,
261
+ argumentName,
262
+ row: caseInsensitiveNameRows[0],
263
+ candidate,
264
+ idField,
265
+ rowField: targetKind,
266
+ });
267
+ }
268
+ if (caseInsensitiveNameRows.length > 1) {
269
+ return buildResolutionFailure({
270
+ targetKind,
271
+ input,
272
+ projectId,
273
+ universeId,
274
+ candidateLimit,
275
+ candidates: caseInsensitiveNameRows.map(row => formatCandidate(row, {
276
+ matchedField: nameField,
277
+ matchType: nameMatchType,
278
+ })),
279
+ });
280
+ }
281
+
282
+ const suggestionsById = rows
283
+ .filter(row => isNearMatch(input, row[idField]))
284
+ .map(row => formatCandidate(row, { matchedField: idField, matchType: "near_match_suggestion" }));
285
+ const suggestedIds = new Set(suggestionsById.map(candidate => candidate.id));
286
+ const suggestionsByName = rows
287
+ .filter(row => !suggestedIds.has(row[idField]) && isNearMatch(input, row[nameField]))
288
+ .map(row => formatCandidate(row, { matchedField: nameField, matchType: "near_match_suggestion" }));
289
+
290
+ return buildResolutionFailure({
291
+ targetKind,
292
+ input,
293
+ projectId,
294
+ universeId,
295
+ candidateLimit,
296
+ candidates: [...suggestionsById, ...suggestionsByName],
297
+ });
298
+ }
299
+
300
+ export function resolveSceneTarget(db, {
301
+ projectId,
302
+ input,
303
+ argumentName = "scene_id",
304
+ candidateLimit = DEFAULT_CANDIDATE_LIMIT,
305
+ } = {}) {
306
+ const rows = db.prepare(`
307
+ SELECT scene_id, project_id, title, chapter_id, chapter_title, timeline_position
308
+ FROM scenes
309
+ WHERE project_id = ?
310
+ ORDER BY title COLLATE NOCASE, scene_id
311
+ `).all(projectId);
312
+
313
+ return resolveFromRows({
314
+ rows,
315
+ input,
316
+ targetKind: "scene",
317
+ idField: "scene_id",
318
+ nameField: "title",
319
+ nameMatchType: "case_insensitive_title",
320
+ argumentName,
321
+ projectId,
322
+ universeId: undefined,
323
+ candidateLimit,
324
+ formatCandidate: formatSceneCandidate,
325
+ });
326
+ }
327
+
328
+ function selectRelationshipScopedCharacters(db, { projectId }) {
329
+ const universeId = getProjectUniverseId(db, projectId);
330
+ return {
331
+ universeId,
332
+ rows: db.prepare(`
333
+ SELECT character_id, project_id, universe_id, name, role, first_appearance
334
+ FROM characters
335
+ WHERE project_id = ?
336
+ OR (universe_id IS NOT NULL AND universe_id = ?)
337
+ OR (project_id IS NULL AND universe_id IS NULL)
338
+ ORDER BY name COLLATE NOCASE, character_id
339
+ `).all(projectId, universeId),
340
+ };
341
+ }
342
+
343
+ function selectRelationshipScopedPlaces(db, { projectId }) {
344
+ const universeId = getProjectUniverseId(db, projectId);
345
+ return {
346
+ universeId,
347
+ rows: db.prepare(`
348
+ SELECT place_id, project_id, universe_id, name
349
+ FROM places
350
+ WHERE project_id = ?
351
+ OR (universe_id IS NOT NULL AND universe_id = ?)
352
+ OR (project_id IS NULL AND universe_id IS NULL)
353
+ ORDER BY name COLLATE NOCASE, place_id
354
+ `).all(projectId, universeId),
355
+ };
356
+ }
357
+
358
+ export function resolveCharacterTargetForProject(db, {
359
+ projectId,
360
+ input,
361
+ argumentName = "character_id",
362
+ candidateLimit = DEFAULT_CANDIDATE_LIMIT,
363
+ } = {}) {
364
+ const { universeId, rows } = selectRelationshipScopedCharacters(db, { projectId });
365
+ return resolveFromRows({
366
+ rows,
367
+ input,
368
+ targetKind: "character",
369
+ idField: "character_id",
370
+ nameField: "name",
371
+ nameMatchType: "case_insensitive_name",
372
+ argumentName,
373
+ projectId,
374
+ universeId,
375
+ candidateLimit,
376
+ formatCandidate: formatCharacterCandidate,
377
+ });
378
+ }
379
+
380
+ export function resolvePlaceTargetForProject(db, {
381
+ projectId,
382
+ input,
383
+ argumentName = "place_id",
384
+ candidateLimit = DEFAULT_CANDIDATE_LIMIT,
385
+ } = {}) {
386
+ const { universeId, rows } = selectRelationshipScopedPlaces(db, { projectId });
387
+ return resolveFromRows({
388
+ rows,
389
+ input,
390
+ targetKind: "place",
391
+ idField: "place_id",
392
+ nameField: "name",
393
+ nameMatchType: "case_insensitive_name",
394
+ argumentName,
395
+ projectId,
396
+ universeId,
397
+ candidateLimit,
398
+ formatCandidate: formatPlaceCandidate,
399
+ });
400
+ }
401
+
402
+ export const CANONICAL_TARGET_CANDIDATE_LIMITS = {
403
+ default: DEFAULT_CANDIDATE_LIMIT,
404
+ hard: HARD_CANDIDATE_LIMIT,
405
+ };