@hanna84/mcp-writing 1.0.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/index.js ADDED
@@ -0,0 +1,892 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
3
+ import http from "node:http";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import matter from "gray-matter";
7
+ import { z } from "zod";
8
+ import { openDb } from "./db.js";
9
+ import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath } from "./sync.js";
10
+
11
+ const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
12
+ const DB_PATH = process.env.DB_PATH ?? "./writing.db";
13
+ const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3000", 10);
14
+ const MAX_CHAPTER_SCENES = parseInt(process.env.MAX_CHAPTER_SCENES ?? "10", 10);
15
+ const DEFAULT_METADATA_PAGE_SIZE = parseInt(process.env.DEFAULT_METADATA_PAGE_SIZE ?? "20", 10);
16
+
17
+ function paginateRows(rows, { page, pageSize, forcePagination = false }) {
18
+ const totalCount = rows.length;
19
+ const shouldPaginate = forcePagination || page !== undefined || pageSize !== undefined;
20
+
21
+ if (!shouldPaginate) {
22
+ return {
23
+ paginated: false,
24
+ rows,
25
+ meta: null,
26
+ };
27
+ }
28
+
29
+ const safePageSize = Math.max(1, pageSize ?? DEFAULT_METADATA_PAGE_SIZE);
30
+ const safePage = Math.max(1, page ?? 1);
31
+ const totalPages = Math.max(1, Math.ceil(totalCount / safePageSize));
32
+ const normalizedPage = Math.min(safePage, totalPages);
33
+ const offset = (normalizedPage - 1) * safePageSize;
34
+ const pageRows = rows.slice(offset, offset + safePageSize);
35
+
36
+ return {
37
+ paginated: true,
38
+ rows: pageRows,
39
+ meta: {
40
+ total_count: totalCount,
41
+ page: normalizedPage,
42
+ page_size: safePageSize,
43
+ total_pages: totalPages,
44
+ has_next_page: normalizedPage < totalPages,
45
+ has_prev_page: normalizedPage > 1,
46
+ },
47
+ };
48
+ }
49
+
50
+ function jsonResponse(payload) {
51
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
52
+ }
53
+
54
+ function errorResponse(code, message, details) {
55
+ const payload = {
56
+ ok: false,
57
+ error: {
58
+ code,
59
+ message,
60
+ ...(details ? { details } : {}),
61
+ },
62
+ };
63
+ return jsonResponse(payload);
64
+ }
65
+
66
+ function deriveLoglineFromProse(prose) {
67
+ const compact = prose.replace(/\s+/g, " ").trim();
68
+ if (!compact) return null;
69
+ const sentence = compact.match(/^(.+?[.!?])(?:\s|$)/);
70
+ const candidate = (sentence?.[1] ?? compact).trim();
71
+ if (candidate.length <= 220) return candidate;
72
+ return `${candidate.slice(0, 217).trimEnd()}...`;
73
+ }
74
+
75
+ function inferCharacterIdsFromProse(dbHandle, prose, projectId) {
76
+ const lower = prose.toLowerCase();
77
+ const rows = dbHandle.prepare(`
78
+ SELECT character_id, name
79
+ FROM characters
80
+ WHERE project_id = ? OR universe_id = (SELECT universe_id FROM projects WHERE project_id = ?)
81
+ ORDER BY length(name) DESC
82
+ `).all(projectId, projectId);
83
+
84
+ const found = [];
85
+ for (const row of rows) {
86
+ if (!row.name) continue;
87
+ const words = row.name.toLowerCase().split(/\s+/).filter(Boolean);
88
+ if (words.length && words.every(w => lower.includes(w))) {
89
+ found.push(row.character_id);
90
+ }
91
+ }
92
+ return [...new Set(found)].slice(0, 12);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Database setup
97
+ // ---------------------------------------------------------------------------
98
+ const db = openDb(DB_PATH);
99
+
100
+ // Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
101
+ const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
102
+ if (!SYNC_DIR_WRITABLE) {
103
+ process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
104
+ }
105
+
106
+ // Run sync on startup
107
+ syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // MCP server factory
111
+ // ---------------------------------------------------------------------------
112
+ function createMcpServer() {
113
+ const s = new McpServer({ name: "mcp-writing", version: "0.1.0" });
114
+
115
+ // ---- sync ----------------------------------------------------------------
116
+ s.tool("sync", "Re-scan the sync folder and update the scene/character/place index from disk. Call this after making edits in Scrivener or updating sidecar files outside the MCP.", {}, async () => {
117
+ const result = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
118
+ const parts = [`Sync complete. ${result.indexed} scenes indexed. ${result.staleMarked} scenes marked stale.`];
119
+ if (result.sidecarsMigrated) parts.push(`${result.sidecarsMigrated} sidecar(s) auto-generated from frontmatter.`);
120
+ if (result.skipped) parts.push(`${result.skipped} file(s) skipped (no scene_id).`);
121
+ if (result.warnings.length) parts.push(`\n⚠️ Warnings:\n` + result.warnings.map(w => `- ${w}`).join("\n"));
122
+ return { content: [{ type: "text", text: parts.join(" ") }] };
123
+ });
124
+
125
+ // ---- find_scenes ---------------------------------------------------------
126
+ s.tool(
127
+ "find_scenes",
128
+ "Find scenes by filtering on character, Save the Cat beat, tags, part, chapter, or POV. Returns ordered scene metadata only — no prose. All filters are optional and combinable. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Warns if any matching scenes have stale metadata.",
129
+ {
130
+ project_id: z.string().optional().describe("Project ID (e.g. 'the-lamb'). Use to scope results to one project."),
131
+ character: z.string().optional().describe("A character_id (e.g. 'char-mira-nystrom'). Returns only scenes that character appears in. Use list_characters first to find valid IDs."),
132
+ beat: z.string().optional().describe("Save the Cat beat name (e.g. 'Opening Image'). Exact match."),
133
+ tag: z.string().optional().describe("Scene tag to filter by. Exact match."),
134
+ part: z.number().int().optional().describe("Part number (integer, e.g. 1). Chapters are numbered globally across the whole project."),
135
+ chapter: z.number().int().optional().describe("Chapter number (integer, e.g. 3). Chapters are numbered globally across the whole project — do not reset per part."),
136
+ pov: z.string().optional().describe("POV character_id. Use list_characters first to find valid IDs."),
137
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
138
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
139
+ },
140
+ async ({ project_id, character, beat, tag, part, chapter, pov, page, page_size }) => {
141
+ let query = `
142
+ SELECT DISTINCT s.scene_id, s.project_id, s.title, s.part, s.chapter, s.pov,
143
+ s.logline, s.scene_change, s.causality, s.stakes, s.scene_functions,
144
+ s.save_the_cat_beat, s.timeline_position, s.story_time,
145
+ s.word_count, s.metadata_stale
146
+ FROM scenes s
147
+ `;
148
+ const joins = [];
149
+ const conditions = [];
150
+ const params = [];
151
+
152
+ if (character) {
153
+ joins.push(`JOIN scene_characters sc ON sc.scene_id = s.scene_id AND sc.character_id = ?`);
154
+ params.push(character);
155
+ }
156
+ if (tag) {
157
+ joins.push(`JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?`);
158
+ params.push(tag);
159
+ }
160
+ if (project_id) { conditions.push(`s.project_id = ?`); params.push(project_id); }
161
+ if (beat) { conditions.push(`s.save_the_cat_beat = ?`); params.push(beat); }
162
+ if (part) { conditions.push(`s.part = ?`); params.push(part); }
163
+ if (chapter) { conditions.push(`s.chapter = ?`); params.push(chapter); }
164
+ if (pov) { conditions.push(`s.pov = ?`); params.push(pov); }
165
+
166
+ if (joins.length) query += " " + joins.join(" ");
167
+ if (conditions.length) query += " WHERE " + conditions.join(" AND ");
168
+ query += " ORDER BY s.part, s.chapter, s.timeline_position";
169
+
170
+ const rows = db.prepare(query).all(...params);
171
+ if (rows.length === 0) {
172
+ return errorResponse("NO_RESULTS", "No scenes match the given filters.");
173
+ }
174
+
175
+ const staleCount = rows.filter(r => r.metadata_stale).length;
176
+ const warning = staleCount > 0
177
+ ? `${staleCount} scene(s) have stale metadata — prose has changed since last enrichment. Consider running enrich_scene() before relying on this data for analysis.`
178
+ : undefined;
179
+
180
+ const paged = paginateRows(rows, {
181
+ page,
182
+ pageSize: page_size,
183
+ forcePagination: rows.length > DEFAULT_METADATA_PAGE_SIZE,
184
+ });
185
+
186
+ const payload = paged.paginated
187
+ ? {
188
+ results: paged.rows,
189
+ ...paged.meta,
190
+ warning,
191
+ }
192
+ : rows;
193
+
194
+ return {
195
+ content: [{
196
+ type: "text",
197
+ text: JSON.stringify(payload, null, 2),
198
+ }],
199
+ };
200
+ }
201
+ );
202
+
203
+ // ---- get_scene_prose -----------------------------------------------------
204
+ s.tool(
205
+ "get_scene_prose",
206
+ "Load the full prose text of a single scene. Use this for close reading, continuity checks, or when you need the actual writing. For overview or filtering, use find_scenes instead — it is much cheaper.",
207
+ {
208
+ scene_id: z.string().describe("The scene_id to retrieve (e.g. 'sc-001-prologue'). Get this from find_scenes or get_arc."),
209
+ },
210
+ async ({ scene_id }) => {
211
+ const scene = db.prepare(`SELECT file_path, metadata_stale FROM scenes WHERE scene_id = ?`).get(scene_id);
212
+ if (!scene) {
213
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Run sync() if you just added it.`);
214
+ }
215
+ try {
216
+ const raw = fs.readFileSync(scene.file_path, "utf8");
217
+ const { content: prose } = matter(raw);
218
+ const warning = scene.metadata_stale
219
+ ? `\n\n⚠️ Metadata for this scene may be stale — prose has changed since last enrichment.`
220
+ : "";
221
+ return { content: [{ type: "text", text: prose.trim() + warning }] };
222
+ } catch (err) {
223
+ if (err.code === "ENOENT") {
224
+ return errorResponse(
225
+ "STALE_PATH",
226
+ `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved since the last sync. Run sync() to refresh the index.`,
227
+ { indexed_path: scene.file_path }
228
+ );
229
+ }
230
+ return errorResponse("IO_ERROR", `Failed to read scene file: ${err.message}`);
231
+ }
232
+ }
233
+ );
234
+
235
+ // ---- get_chapter_prose ---------------------------------------------------
236
+ s.tool(
237
+ "get_chapter_prose",
238
+ `Load the full prose for every scene in a chapter, concatenated in order. Expensive — only use when you need to read an entire chapter. Capped at ${MAX_CHAPTER_SCENES} scenes. Use find_scenes first to confirm the chapter exists.`,
239
+ {
240
+ project_id: z.string().describe("Project ID (e.g. 'the-lamb')."),
241
+ part: z.number().int().describe("Part number (integer)."),
242
+ chapter: z.number().int().describe("Chapter number (integer, globally numbered across the whole project)."),
243
+ },
244
+ async ({ project_id, part, chapter }) => {
245
+ const allScenes = db.prepare(`
246
+ SELECT scene_id, title, file_path FROM scenes
247
+ WHERE project_id = ? AND part = ? AND chapter = ?
248
+ ORDER BY timeline_position
249
+ `).all(project_id, part, chapter);
250
+
251
+ if (allScenes.length === 0) {
252
+ return errorResponse("NO_RESULTS", `No scenes found for Part ${part}, Chapter ${chapter}.`);
253
+ }
254
+
255
+ const truncated = allScenes.length > MAX_CHAPTER_SCENES;
256
+ const scenes = truncated ? allScenes.slice(0, MAX_CHAPTER_SCENES) : allScenes;
257
+
258
+ const parts = [];
259
+ for (const scene of scenes) {
260
+ try {
261
+ const raw = fs.readFileSync(scene.file_path, "utf8");
262
+ const { content: prose } = matter(raw);
263
+ parts.push(`## ${scene.title ?? scene.scene_id}\n\n${prose.trim()}`);
264
+ } catch (err) {
265
+ parts.push(`## ${scene.scene_id}\n\n[Error reading file: ${err.message}]`);
266
+ }
267
+ }
268
+
269
+ const warning = truncated
270
+ ? `\n\n⚠️ Chapter has ${allScenes.length} scenes — only the first ${MAX_CHAPTER_SCENES} were loaded. Set MAX_CHAPTER_SCENES to increase this limit.`
271
+ : "";
272
+ return { content: [{ type: "text", text: parts.join("\n\n---\n\n") + warning }] };
273
+ }
274
+ );
275
+
276
+ // ---- get_arc -------------------------------------------------------------
277
+ s.tool(
278
+ "get_arc",
279
+ "Get every scene a character appears in, ordered by part/chapter/position. Returns scene metadata only — no prose. Use this to trace a character's arc through the story. Supports pagination via page/page_size and auto-paginates large result sets with total_count. Call list_characters first to get the character_id.",
280
+ {
281
+ character_id: z.string().describe("The character_id to trace (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
282
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
283
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
284
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
285
+ },
286
+ async ({ character_id, project_id, page, page_size }) => {
287
+ let query = `
288
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
289
+ s.scene_change, s.causality, s.stakes, s.scene_functions,
290
+ s.save_the_cat_beat, s.timeline_position, s.story_time, s.pov, s.metadata_stale
291
+ FROM scenes s
292
+ JOIN scene_characters sc ON sc.scene_id = s.scene_id
293
+ WHERE sc.character_id = ?
294
+ `;
295
+ const params = [character_id];
296
+ if (project_id) { query += ` AND s.project_id = ?`; params.push(project_id); }
297
+ query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
298
+
299
+ const rows = db.prepare(query).all(...params);
300
+ if (rows.length === 0) {
301
+ return errorResponse("NO_RESULTS", `No scenes found for character '${character_id}'.`);
302
+ }
303
+
304
+ const staleCount = rows.filter(r => r.metadata_stale).length;
305
+ const warning = staleCount > 0
306
+ ? `${staleCount} scene(s) have stale metadata.`
307
+ : undefined;
308
+
309
+ const paged = paginateRows(rows, {
310
+ page,
311
+ pageSize: page_size,
312
+ forcePagination: rows.length > DEFAULT_METADATA_PAGE_SIZE,
313
+ });
314
+
315
+ const payload = paged.paginated
316
+ ? {
317
+ results: paged.rows,
318
+ ...paged.meta,
319
+ warning,
320
+ }
321
+ : rows;
322
+
323
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
324
+ }
325
+ );
326
+
327
+ // ---- list_characters -----------------------------------------------------
328
+ s.tool(
329
+ "list_characters",
330
+ "List all indexed characters with their character_id, name, role, and arc_summary. Call this first whenever you need to filter scenes by character or look up a character sheet — it gives you the character_id values required by other tools.",
331
+ {
332
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
333
+ universe_id: z.string().optional().describe("Limit to a specific universe (if using cross-project world-building)."),
334
+ },
335
+ async ({ project_id, universe_id }) => {
336
+ let query = `SELECT character_id, name, role, arc_summary, project_id, universe_id FROM characters`;
337
+ const conditions = [];
338
+ const params = [];
339
+ if (project_id) { conditions.push(`project_id = ?`); params.push(project_id); }
340
+ if (universe_id) { conditions.push(`universe_id = ?`); params.push(universe_id); }
341
+ if (conditions.length) query += " WHERE " + conditions.join(" AND ");
342
+ query += " ORDER BY name";
343
+
344
+ const rows = db.prepare(query).all(...params);
345
+ if (rows.length === 0) {
346
+ return errorResponse("NO_RESULTS", "No characters found.");
347
+ }
348
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
349
+ }
350
+ );
351
+
352
+ // ---- get_character_sheet -------------------------------------------------
353
+ s.tool(
354
+ "get_character_sheet",
355
+ "Get full character details: role, arc_summary, traits, and the full content of the character notes file. Use list_characters first to get the character_id.",
356
+ {
357
+ character_id: z.string().describe("The character_id to look up (e.g. 'char-sebastian'). Use list_characters to find valid IDs."),
358
+ },
359
+ async ({ character_id }) => {
360
+ const character = db.prepare(`SELECT * FROM characters WHERE character_id = ?`).get(character_id);
361
+ if (!character) {
362
+ return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
363
+ }
364
+
365
+ const traits = db.prepare(`SELECT trait FROM character_traits WHERE character_id = ?`)
366
+ .all(character_id).map(r => r.trait);
367
+
368
+ let notes = "";
369
+ if (character.file_path) {
370
+ try {
371
+ const raw = fs.readFileSync(character.file_path, "utf8");
372
+ const { content } = matter(raw);
373
+ notes = content.trim();
374
+ } catch {}
375
+ }
376
+
377
+ const result = { ...character, traits, notes: notes || undefined };
378
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
379
+ }
380
+ );
381
+
382
+ // ---- list_places ---------------------------------------------------------
383
+ s.tool(
384
+ "list_places",
385
+ "List all indexed places with their place_id and name. Use this to find place_id values for scene filtering or to get an overview of the story's locations.",
386
+ {
387
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
388
+ universe_id: z.string().optional().describe("Limit to a specific universe."),
389
+ },
390
+ async ({ project_id, universe_id }) => {
391
+ let query = `SELECT place_id, name, project_id, universe_id FROM places`;
392
+ const conditions = [];
393
+ const params = [];
394
+ if (project_id) { conditions.push(`project_id = ?`); params.push(project_id); }
395
+ if (universe_id) { conditions.push(`universe_id = ?`); params.push(universe_id); }
396
+ if (conditions.length) query += " WHERE " + conditions.join(" AND ");
397
+ query += " ORDER BY name";
398
+
399
+ const rows = db.prepare(query).all(...params);
400
+ if (rows.length === 0) {
401
+ return errorResponse("NO_RESULTS", "No places found.");
402
+ }
403
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
404
+ }
405
+ );
406
+
407
+ // ---- search_metadata -----------------------------------------------------
408
+ s.tool(
409
+ "search_metadata",
410
+ "Full-text search across scene titles and loglines (synopsis/logline text fields). Use this when you don't know the exact scene_id or chapter but want to find scenes by topic, theme, or keywords in the description. Not a prose search — use get_scene_prose to read actual text. Supports pagination via page/page_size and auto-paginates large result sets with total_count.",
411
+ {
412
+ query: z.string().describe("Search terms (e.g. 'hospital' or 'Sebastian feeding'). FTS5 syntax supported."),
413
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
414
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
415
+ },
416
+ async ({ query, page, page_size }) => {
417
+ let totalCount;
418
+ try {
419
+ totalCount = db.prepare(`
420
+ SELECT COUNT(*) AS count
421
+ FROM scenes_fts f
422
+ JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
423
+ WHERE scenes_fts MATCH ?
424
+ `).get(query)?.count ?? 0;
425
+ } catch (err) {
426
+ return errorResponse("INVALID_QUERY", "Invalid search query syntax. Use plain keywords or quoted phrases.", { detail: err.message });
427
+ }
428
+
429
+ if (totalCount === 0) {
430
+ return errorResponse("NO_RESULTS", "No scenes matched the search query.");
431
+ }
432
+
433
+ const shouldPaginate = totalCount > DEFAULT_METADATA_PAGE_SIZE || page !== undefined || page_size !== undefined;
434
+
435
+ if (!shouldPaginate) {
436
+ const rows = db.prepare(`
437
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
438
+ FROM scenes_fts f
439
+ JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
440
+ WHERE scenes_fts MATCH ?
441
+ ORDER BY rank
442
+ `).all(query);
443
+
444
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
445
+ }
446
+
447
+ const safePageSize = Math.max(1, page_size ?? DEFAULT_METADATA_PAGE_SIZE);
448
+ const safePage = Math.max(1, page ?? 1);
449
+ const totalPages = Math.max(1, Math.ceil(totalCount / safePageSize));
450
+ const normalizedPage = Math.min(safePage, totalPages);
451
+ const offset = (normalizedPage - 1) * safePageSize;
452
+
453
+ const rows = db.prepare(`
454
+ SELECT f.scene_id, f.project_id, s.title, s.logline, s.part, s.chapter, s.metadata_stale
455
+ FROM scenes_fts f
456
+ JOIN scenes s ON s.scene_id = f.scene_id AND s.project_id = f.project_id
457
+ WHERE scenes_fts MATCH ?
458
+ ORDER BY rank
459
+ LIMIT ? OFFSET ?
460
+ `).all(query, safePageSize, offset);
461
+
462
+ const payload = {
463
+ results: rows,
464
+ total_count: totalCount,
465
+ page: normalizedPage,
466
+ page_size: safePageSize,
467
+ total_pages: totalPages,
468
+ has_next_page: normalizedPage < totalPages,
469
+ has_prev_page: normalizedPage > 1,
470
+ };
471
+
472
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
473
+ }
474
+ );
475
+
476
+ // ---- list_threads --------------------------------------------------------
477
+ s.tool(
478
+ "list_threads",
479
+ "List all subplot/storyline threads for a project. Returns a structured JSON envelope with results and total_count. Supports pagination via page/page_size.",
480
+ {
481
+ project_id: z.string().describe("Project ID."),
482
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
483
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
484
+ },
485
+ async ({ project_id, page, page_size }) => {
486
+ const rows = db.prepare(`SELECT * FROM threads WHERE project_id = ? ORDER BY name`).all(project_id);
487
+ const paged = paginateRows(rows, { page, pageSize: page_size, forcePagination: false });
488
+ const payload = paged.paginated
489
+ ? {
490
+ project_id,
491
+ results: paged.rows,
492
+ ...paged.meta,
493
+ }
494
+ : {
495
+ project_id,
496
+ results: rows,
497
+ total_count: rows.length,
498
+ };
499
+
500
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
501
+ }
502
+ );
503
+
504
+ // ---- get_thread_arc ------------------------------------------------------
505
+ s.tool(
506
+ "get_thread_arc",
507
+ "Get ordered scene metadata for all scenes belonging to a thread, including the per-thread beat. Returns a structured JSON envelope with thread metadata, results, and total_count. Supports pagination via page/page_size.",
508
+ {
509
+ thread_id: z.string().describe("Thread ID."),
510
+ page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
511
+ page_size: z.number().int().min(1).max(200).optional().describe("Optional page size for paginated responses (default: 20, max: 200)."),
512
+ },
513
+ async ({ thread_id, page, page_size }) => {
514
+ const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
515
+ if (!thread) {
516
+ return errorResponse("NOT_FOUND", `Thread '${thread_id}' not found.`);
517
+ }
518
+
519
+ const rows = db.prepare(`
520
+ SELECT s.scene_id, s.project_id, s.part, s.chapter, s.title, s.logline,
521
+ st.beat AS thread_beat, s.timeline_position, s.story_time, s.metadata_stale
522
+ FROM scenes s
523
+ JOIN scene_threads st ON st.scene_id = s.scene_id AND st.thread_id = ?
524
+ ORDER BY s.part, s.chapter, s.timeline_position
525
+ `).all(thread_id);
526
+ const staleCount = rows.filter(r => r.metadata_stale).length;
527
+ const warning = staleCount > 0 ? `${staleCount} scene(s) have stale metadata.` : undefined;
528
+ const paged = paginateRows(rows, { page, pageSize: page_size, forcePagination: false });
529
+
530
+ const payload = paged.paginated
531
+ ? {
532
+ thread,
533
+ results: paged.rows,
534
+ ...paged.meta,
535
+ warning,
536
+ }
537
+ : {
538
+ thread,
539
+ results: rows,
540
+ total_count: rows.length,
541
+ warning,
542
+ };
543
+
544
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
545
+ }
546
+ );
547
+
548
+ // ---- upsert_thread_link ---------------------------------------------------
549
+ s.tool(
550
+ "upsert_thread_link",
551
+ "Create or update a thread and link it to a scene. Idempotent: if the link already exists, updates its beat. Only available when the sync dir is writable.",
552
+ {
553
+ project_id: z.string().describe("Project the thread belongs to (e.g. 'the-lamb')."),
554
+ thread_id: z.string().describe("Thread ID (e.g. 'thread-reconciliation')."),
555
+ thread_name: z.string().describe("Thread display name."),
556
+ scene_id: z.string().describe("Scene to link to the thread (e.g. 'sc-011-sebastian')."),
557
+ beat: z.string().optional().describe("Optional thread-specific beat label for this scene."),
558
+ status: z.string().optional().describe("Thread status (e.g. 'active', 'resolved'). Defaults to 'active'."),
559
+ },
560
+ async ({ project_id, thread_id, thread_name, scene_id, beat, status }) => {
561
+ if (!SYNC_DIR_WRITABLE) {
562
+ return errorResponse("READ_ONLY", "Cannot write thread links: sync dir is read-only.");
563
+ }
564
+
565
+ const existingThread = db.prepare(`SELECT thread_id, project_id FROM threads WHERE thread_id = ?`).get(thread_id);
566
+ if (existingThread && existingThread.project_id !== project_id) {
567
+ return errorResponse(
568
+ "CONFLICT",
569
+ `Thread '${thread_id}' already exists in project '${existingThread.project_id}', cannot reuse it for project '${project_id}'.`
570
+ );
571
+ }
572
+
573
+ const scene = db.prepare(`SELECT scene_id FROM scenes WHERE scene_id = ? AND project_id = ?`).get(scene_id, project_id);
574
+ if (!scene) {
575
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
576
+ }
577
+
578
+ db.prepare(`
579
+ INSERT INTO threads (thread_id, project_id, name, status)
580
+ VALUES (?, ?, ?, ?)
581
+ ON CONFLICT (thread_id) DO UPDATE SET
582
+ name = excluded.name,
583
+ status = excluded.status
584
+ `).run(thread_id, project_id, thread_name, status ?? "active");
585
+
586
+ db.prepare(`
587
+ INSERT INTO scene_threads (scene_id, thread_id, beat)
588
+ VALUES (?, ?, ?)
589
+ ON CONFLICT (scene_id, thread_id) DO UPDATE SET
590
+ beat = excluded.beat
591
+ `).run(scene_id, thread_id, beat ?? null);
592
+
593
+ const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
594
+ const link = db.prepare(`SELECT scene_id, thread_id, beat FROM scene_threads WHERE scene_id = ? AND thread_id = ?`)
595
+ .get(scene_id, thread_id);
596
+
597
+ return jsonResponse({
598
+ ok: true,
599
+ action: "upserted",
600
+ thread,
601
+ link,
602
+ });
603
+ }
604
+ );
605
+
606
+ // ---- enrich_scene --------------------------------------------------------
607
+ s.tool(
608
+ "enrich_scene",
609
+ "Re-derive lightweight scene metadata from current prose (logline and character mentions) and clear metadata_stale for that scene. Only available when the sync dir is writable.",
610
+ {
611
+ scene_id: z.string().describe("Scene to enrich (e.g. 'sc-011-sebastian')."),
612
+ project_id: z.string().optional().describe("Project ID. Required when scene_id is duplicated across projects."),
613
+ },
614
+ async ({ scene_id, project_id }) => {
615
+ if (!SYNC_DIR_WRITABLE) {
616
+ return errorResponse("READ_ONLY", "Cannot enrich scene: sync dir is read-only.");
617
+ }
618
+
619
+ let scene;
620
+ if (project_id) {
621
+ scene = db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
622
+ .get(scene_id, project_id);
623
+ } else {
624
+ const matches = db.prepare(`SELECT scene_id, project_id, file_path FROM scenes WHERE scene_id = ?`).all(scene_id);
625
+ if (matches.length > 1) {
626
+ return errorResponse("VALIDATION_ERROR", `Scene '${scene_id}' exists in multiple projects. Provide project_id.`);
627
+ }
628
+ scene = matches[0];
629
+ }
630
+
631
+ if (!scene) {
632
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found${project_id ? ` in project '${project_id}'` : ""}.`);
633
+ }
634
+
635
+ try {
636
+ const raw = fs.readFileSync(scene.file_path, "utf8");
637
+ const { content: prose } = matter(raw);
638
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
639
+
640
+ const inferredLogline = deriveLoglineFromProse(prose);
641
+ const inferredCharacters = inferCharacterIdsFromProse(db, prose, scene.project_id);
642
+
643
+ const updatedMeta = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, {
644
+ ...meta,
645
+ ...(inferredLogline ? { logline: inferredLogline } : {}),
646
+ ...((inferredCharacters.length > 0 || (meta.characters?.length ?? 0) > 0)
647
+ ? { characters: inferredCharacters.length > 0 ? inferredCharacters : meta.characters }
648
+ : {}),
649
+ }).meta;
650
+
651
+ writeMeta(scene.file_path, updatedMeta);
652
+ indexSceneFile(db, SYNC_DIR, scene.file_path, updatedMeta, prose);
653
+ db.prepare(`UPDATE scenes SET metadata_stale = 0 WHERE scene_id = ? AND project_id = ?`)
654
+ .run(scene.scene_id, scene.project_id);
655
+
656
+ return jsonResponse({
657
+ ok: true,
658
+ action: "enriched",
659
+ scene_id: scene.scene_id,
660
+ project_id: scene.project_id,
661
+ updated_fields: {
662
+ logline: Boolean(inferredLogline),
663
+ characters: inferredCharacters.length,
664
+ },
665
+ metadata_stale: false,
666
+ });
667
+ } catch (err) {
668
+ return errorResponse("IO_ERROR", `Failed to enrich scene '${scene.scene_id}': ${err.message}`);
669
+ }
670
+ }
671
+ );
672
+
673
+ // ---- update_scene_metadata -----------------------------------------------
674
+ s.tool(
675
+ "update_scene_metadata",
676
+ "Update one or more metadata fields for a scene. Writes to the .meta.yaml sidecar — never modifies prose. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
677
+ {
678
+ scene_id: z.string().describe("The scene_id to update (e.g. 'sc-011-sebastian')."),
679
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
680
+ fields: z.object({
681
+ title: z.string().optional(),
682
+ logline: z.string().optional(),
683
+ save_the_cat_beat: z.string().optional(),
684
+ pov: z.string().optional(),
685
+ part: z.number().int().optional(),
686
+ chapter: z.number().int().optional(),
687
+ timeline_position: z.number().int().optional(),
688
+ story_time: z.string().optional(),
689
+ tags: z.array(z.string()).optional(),
690
+ characters: z.array(z.string()).optional(),
691
+ places: z.array(z.string()).optional(),
692
+ }).describe("Fields to update. Only supplied keys are changed."),
693
+ },
694
+ async ({ scene_id, project_id, fields }) => {
695
+ if (!SYNC_DIR_WRITABLE) {
696
+ return errorResponse("READ_ONLY", "Cannot update metadata: sync dir is read-only.");
697
+ }
698
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
699
+ .get(scene_id, project_id);
700
+ if (!scene) {
701
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
702
+ }
703
+ try {
704
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
705
+ const updated = normalizeSceneMetaForPath(SYNC_DIR, scene.file_path, { ...meta, ...fields }).meta;
706
+ writeMeta(scene.file_path, updated);
707
+
708
+ // Re-index the scene immediately so the DB reflects the new metadata
709
+ const { content: prose } = matter(fs.readFileSync(scene.file_path, "utf8"));
710
+ indexSceneFile(db, SYNC_DIR, scene.file_path, updated, prose);
711
+
712
+ return { content: [{ type: "text", text: `Updated metadata for scene '${scene_id}'.` }] };
713
+ } catch (err) {
714
+ if (err.code === "ENOENT") {
715
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
716
+ }
717
+ return errorResponse("IO_ERROR", `Failed to write metadata for scene '${scene_id}': ${err.message}`);
718
+ }
719
+ }
720
+ );
721
+
722
+ // ---- update_character_sheet ----------------------------------------------
723
+ s.tool(
724
+ "update_character_sheet",
725
+ "Update structured metadata fields for a character (role, arc_summary, traits, etc). Writes to the .meta.yaml sidecar — never modifies the prose notes file. Changes are immediately reflected in the index. Only available when the sync dir is writable.",
726
+ {
727
+ character_id: z.string().describe("The character_id to update (e.g. 'char-mira-nystrom'). Use list_characters to find valid IDs."),
728
+ fields: z.object({
729
+ name: z.string().optional(),
730
+ role: z.string().optional(),
731
+ arc_summary: z.string().optional(),
732
+ first_appearance: z.string().optional(),
733
+ traits: z.array(z.string()).optional(),
734
+ }).describe("Fields to update. Only supplied keys are changed."),
735
+ },
736
+ async ({ character_id, fields }) => {
737
+ if (!SYNC_DIR_WRITABLE) {
738
+ return errorResponse("READ_ONLY", "Cannot update character: sync dir is read-only.");
739
+ }
740
+ const char = db.prepare(`SELECT file_path FROM characters WHERE character_id = ?`).get(character_id);
741
+ if (!char) {
742
+ return errorResponse("NOT_FOUND", `Character '${character_id}' not found.`);
743
+ }
744
+ try {
745
+ const { meta } = readMeta(char.file_path, SYNC_DIR, { writable: true });
746
+ const updated = { ...meta, ...fields };
747
+ writeMeta(char.file_path, updated);
748
+
749
+ // Update DB directly
750
+ db.prepare(`
751
+ UPDATE characters SET name = ?, role = ?, arc_summary = ?, first_appearance = ?
752
+ WHERE character_id = ?
753
+ `).run(
754
+ updated.name ?? meta.name, updated.role ?? null,
755
+ updated.arc_summary ?? null, updated.first_appearance ?? null,
756
+ character_id
757
+ );
758
+ if (fields.traits) {
759
+ db.prepare(`DELETE FROM character_traits WHERE character_id = ?`).run(character_id);
760
+ for (const t of fields.traits) {
761
+ db.prepare(`INSERT OR IGNORE INTO character_traits (character_id, trait) VALUES (?, ?)`).run(character_id, t);
762
+ }
763
+ }
764
+
765
+ return { content: [{ type: "text", text: `Updated character sheet for '${character_id}'.` }] };
766
+ } catch (err) {
767
+ if (err.code === "ENOENT") {
768
+ return errorResponse("STALE_PATH", `Character file for '${character_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: char.file_path });
769
+ }
770
+ return errorResponse("IO_ERROR", `Failed to write character metadata for '${character_id}': ${err.message}`);
771
+ }
772
+ }
773
+ );
774
+
775
+ // ---- flag_scene ----------------------------------------------------------
776
+ s.tool(
777
+ "flag_scene",
778
+ "Attach a continuity or review note to a scene. Flags are appended to the sidecar file and accumulate over time — they are never overwritten. Use this to record continuity problems, revision notes, or questions you want to revisit.",
779
+ {
780
+ scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
781
+ project_id: z.string().describe("Project the scene belongs to (e.g. 'the-lamb')."),
782
+ note: z.string().describe("The flag note (e.g. 'Victor knows Mira\'s name here, but they haven\'t been introduced yet — contradicts sc-006')."),
783
+ },
784
+ async ({ scene_id, project_id, note }) => {
785
+ if (!SYNC_DIR_WRITABLE) {
786
+ return errorResponse("READ_ONLY", "Cannot flag scene: sync dir is read-only.");
787
+ }
788
+ const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ? AND project_id = ?`)
789
+ .get(scene_id, project_id);
790
+ if (!scene) {
791
+ return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found in project '${project_id}'.`);
792
+ }
793
+ try {
794
+ const { meta } = readMeta(scene.file_path, SYNC_DIR, { writable: true });
795
+ const flags = meta.flags ?? [];
796
+ flags.push({ note, flagged_at: new Date().toISOString() });
797
+ writeMeta(scene.file_path, { ...meta, flags });
798
+ return { content: [{ type: "text", text: `Flagged scene '${scene_id}': ${note}` }] };
799
+ } catch (err) {
800
+ if (err.code === "ENOENT") {
801
+ return errorResponse("STALE_PATH", `Prose file for scene '${scene_id}' not found at indexed path — the file may have moved. Run sync() to refresh.`, { indexed_path: scene.file_path });
802
+ }
803
+ return errorResponse("IO_ERROR", `Failed to flag scene '${scene_id}': ${err.message}`);
804
+ }
805
+ }
806
+ );
807
+
808
+ // ---- get_relationship_arc ------------------------------------------------
809
+ s.tool(
810
+ "get_relationship_arc",
811
+ "Show how the relationship between two characters evolves across scenes, in order. Uses explicitly recorded relationship entries — returns nothing if no entries exist yet. Use list_characters to get character_id values.",
812
+ {
813
+ from_character: z.string().describe("character_id of the first character (e.g. 'char-sebastian')."),
814
+ to_character: z.string().describe("character_id of the second character (e.g. 'char-mira-nystrom')."),
815
+ project_id: z.string().optional().describe("Limit to a specific project (e.g. 'the-lamb')."),
816
+ },
817
+ async ({ from_character, to_character, project_id }) => {
818
+ let query = `
819
+ SELECT r.from_character, r.to_character, r.relationship_type, r.strength,
820
+ r.scene_id, r.note,
821
+ s.part, s.chapter, s.timeline_position, s.title AS scene_title
822
+ FROM character_relationships r
823
+ LEFT JOIN scenes s ON s.scene_id = r.scene_id
824
+ WHERE r.from_character = ? AND r.to_character = ?
825
+ `;
826
+ const params = [from_character, to_character];
827
+ if (project_id) { query += ` AND (s.project_id = ? OR r.scene_id IS NULL)`; params.push(project_id); }
828
+ query += ` ORDER BY s.part, s.chapter, s.timeline_position`;
829
+
830
+ const rows = db.prepare(query).all(...params);
831
+ if (rows.length === 0) {
832
+ return errorResponse("NO_RESULTS", `No relationship data found between '${from_character}' and '${to_character}'.`);
833
+ }
834
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
835
+ }
836
+ );
837
+
838
+ return s;
839
+ }
840
+
841
+ // ---------------------------------------------------------------------------
842
+ // HTTP server
843
+ // ---------------------------------------------------------------------------
844
+ const activeSessions = new Map();
845
+
846
+ const httpServer = http.createServer(async (req, res) => {
847
+ if (req.method === "GET" && req.url === "/sse") {
848
+ const transport = new SSEServerTransport("/message", res);
849
+ const sessionId = transport.sessionId;
850
+
851
+ const existing = activeSessions.get(sessionId);
852
+ if (existing) {
853
+ try { await existing.transport.close(); } catch {}
854
+ try { await existing.server.close(); } catch {}
855
+ activeSessions.delete(sessionId);
856
+ }
857
+
858
+ const sessionServer = createMcpServer();
859
+ activeSessions.set(sessionId, { transport, server: sessionServer });
860
+ res.on("close", () => activeSessions.delete(sessionId));
861
+
862
+ await sessionServer.connect(transport);
863
+ process.stderr.write(`[mcp-writing] SSE client connected (session=${sessionId})\n`);
864
+ return;
865
+ }
866
+
867
+ if (req.method === "POST" && req.url.startsWith("/message")) {
868
+ const url = new URL(req.url, `http://localhost`);
869
+ const sessionId = url.searchParams.get("sessionId");
870
+ const session = sessionId ? activeSessions.get(sessionId) : null;
871
+ if (!session) {
872
+ res.writeHead(404, { "Content-Type": "text/plain" });
873
+ res.end("Session not found");
874
+ return;
875
+ }
876
+ await session.transport.handlePostMessage(req, res);
877
+ return;
878
+ }
879
+
880
+ if (req.method === "GET" && req.url === "/healthz") {
881
+ res.writeHead(200, { "Content-Type": "text/plain" });
882
+ res.end("ok");
883
+ return;
884
+ }
885
+
886
+ res.writeHead(404, { "Content-Type": "text/plain" });
887
+ res.end("Not found");
888
+ });
889
+
890
+ httpServer.listen(HTTP_PORT, () => {
891
+ process.stderr.write(`[mcp-writing] Listening on port ${HTTP_PORT}\n`);
892
+ });