@hanna84/mcp-writing 2.12.16 → 2.12.17

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.12.17](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.12.16...v2.12.17)
9
+
10
+ - fix: restrict package files allowlist after src test move [`#140`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/140)
12
+
7
13
  #### [v2.12.16](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.12.15...v2.12.16)
9
15
 
16
+ > 30 April 2026
17
+
10
18
  - refactor(src): move scripts and tests under src [`#139`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/139)
20
+ - Release 2.12.16 [`cd34af2`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/cd34af28c4a06e7c44d78b9b9ef8f185bf063222)
12
22
 
13
23
  #### [v2.12.15](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.12.14...v2.12.15)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.12.16",
3
+ "version": "2.12.17",
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",
@@ -41,7 +41,16 @@
41
41
  "files": [
42
42
  "bin/",
43
43
  "index.js",
44
- "src/",
44
+ "src/index.js",
45
+ "src/core/",
46
+ "src/review-bundles/",
47
+ "src/runtime/",
48
+ "src/scripts/",
49
+ "src/styleguide/",
50
+ "src/sync/",
51
+ "src/tools/",
52
+ "src/workflows/",
53
+ "src/world/",
45
54
  "README.md",
46
55
  "CHANGELOG.md"
47
56
  ],
@@ -1,47 +0,0 @@
1
- import { openDb } from "../../core/db.js";
2
-
3
- export function insertTestScene(db, {
4
- sceneId,
5
- projectId = "test-novel",
6
- title = null,
7
- part = null,
8
- chapter = null,
9
- timelinePosition = null,
10
- metadataStale = 0,
11
- wordCount = null,
12
- }) {
13
- const now = new Date().toISOString();
14
- db.prepare(`
15
- INSERT INTO scenes (
16
- scene_id,
17
- project_id,
18
- title,
19
- part,
20
- chapter,
21
- timeline_position,
22
- word_count,
23
- file_path,
24
- prose_checksum,
25
- metadata_stale,
26
- updated_at
27
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
28
- `).run(
29
- sceneId,
30
- projectId,
31
- title,
32
- part,
33
- chapter,
34
- timelinePosition,
35
- wordCount,
36
- `/tmp/${sceneId}.md`,
37
- "deadbeef",
38
- metadataStale,
39
- now
40
- );
41
- }
42
-
43
- export function setupReviewBundleTestDb() {
44
- const db = openDb(":memory:");
45
- db.prepare(`INSERT INTO projects (project_id, universe_id, name) VALUES (?, ?, ?)`).run("test-novel", null, "Test Novel");
46
- return db;
47
- }
@@ -1,380 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- export function copyDirSync(src, dest) {
5
- fs.mkdirSync(dest, { recursive: true });
6
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
7
- const srcPath = path.join(src, entry.name);
8
- const destPath = path.join(dest, entry.name);
9
- if (entry.isDirectory()) copyDirSync(srcPath, destPath);
10
- else fs.copyFileSync(srcPath, destPath);
11
- }
12
- }
13
-
14
- export function writeFileSyncWithDirs(filePath, content) {
15
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
16
- fs.writeFileSync(filePath, content, "utf8");
17
- }
18
-
19
- export function createTestSyncFixture(syncDir) {
20
- writeFileSyncWithDirs(
21
- path.join(syncDir, "projects", "test-novel", "part-1", "chapter-1", "sc-001.md"),
22
- `---
23
- scene_id: sc-001
24
- title: The Return
25
- part: 1
26
- chapter: 1
27
- characters: [elena, marcus]
28
- places: [harbor-district]
29
- logline: Elena returns to the harbor district after three years away and runs into Marcus.
30
- save_the_cat: Opening Image
31
- pov: elena
32
- timeline_position: 1
33
- story_time: "Day 1, late afternoon"
34
- tags: [reunion, tension, harbor]
35
- ---
36
-
37
- The ferry docked at quarter past four, which meant Elena had seventeen minutes before the evening freight shift began and the harbor became impassable. She had timed it deliberately. She did not want to see anyone she knew.
38
-
39
- She was at the bottom of the gangway when she heard her name.
40
-
41
- Marcus was standing by the storage shed with a clipboard in one hand and an expression she recognized -- the particular look he got when he was pretending not to be surprised. He was very bad at pretending.
42
-
43
- "You could have called," he said.
44
-
45
- "I could have," she agreed, and kept walking.
46
-
47
- He fell into step beside her anyway, which was exactly what she had expected him to do.
48
- `
49
- );
50
-
51
- writeFileSyncWithDirs(
52
- path.join(syncDir, "projects", "test-novel", "part-1", "chapter-1", "sc-001.meta.yaml"),
53
- `scene_id: sc-001
54
- title: The Return
55
- part: 1
56
- chapter: 1
57
- characters:
58
- - elena
59
- - marcus
60
- places:
61
- - harbor-district
62
- logline: >-
63
- Elena returns to the harbor district after three years away and runs into
64
- Marcus.
65
- save_the_cat: Opening Image
66
- pov: elena
67
- timeline_position: 1
68
- story_time: 'Day 1, late afternoon'
69
- tags:
70
- - reunion
71
- - tension
72
- - harbor
73
- `
74
- );
75
-
76
- writeFileSyncWithDirs(
77
- path.join(syncDir, "projects", "test-novel", "part-1", "chapter-1", "sc-002.md"),
78
- `---
79
- scene_id: sc-002
80
- title: The Argument
81
- part: 1
82
- chapter: 1
83
- characters: [elena, marcus]
84
- places: [harbor-district]
85
- logline: Elena and Marcus argue about why she left; she deflects, he pushes back harder than before.
86
- save_the_cat: Theme Stated
87
- pov: elena
88
- timeline_position: 2
89
- story_time: "Day 1, evening"
90
- tags: [conflict, backstory, harbor]
91
- ---
92
-
93
- They ended up at the old bait shed because the wind had picked up and it was the nearest shelter. The shed smelled the same as it always had -- salt and something faintly chemical. Elena had spent half her childhood in this shed. She wished she were somewhere else.
94
-
95
- "You didn't call me," Marcus said. "You didn't write. Three years."
96
-
97
- "I was busy."
98
-
99
- "Everyone is busy. That's not an answer."
100
-
101
- She looked at the water instead of him. "It's the one I've got."
102
-
103
- He was quiet for a long time. When he spoke again, his voice had changed -- less patient, more tired. "I'm not angry you left, Elena. I'm angry you decided I wouldn't understand."
104
-
105
- She didn't have an answer for that either.
106
- `
107
- );
108
-
109
- writeFileSyncWithDirs(
110
- path.join(syncDir, "projects", "test-novel", "part-1", "chapter-1", "sc-002.meta.yaml"),
111
- `scene_id: sc-002
112
- title: The Argument
113
- part: 1
114
- chapter: 1
115
- characters:
116
- - elena
117
- - marcus
118
- places:
119
- - harbor-district
120
- logline: >-
121
- Elena and Marcus argue about why she left; she deflects, he pushes back harder
122
- than before.
123
- save_the_cat: Theme Stated
124
- pov: elena
125
- timeline_position: 2
126
- story_time: 'Day 1, evening'
127
- tags:
128
- - conflict
129
- - backstory
130
- - harbor
131
- - Daniel Nystrom
132
- `
133
- );
134
-
135
- writeFileSyncWithDirs(
136
- path.join(syncDir, "projects", "test-novel", "part-1", "chapter-2", "sc-003.md"),
137
- `---
138
- scene_id: sc-003
139
- title: The Offer
140
- part: 1
141
- chapter: 2
142
- characters: [elena]
143
- places: [harbor-district]
144
- logline: Elena receives an envelope at her old address -- an offer she doesn't understand yet, but can't ignore.
145
- save_the_cat: Catalyst
146
- pov: elena
147
- timeline_position: 3
148
- story_time: "Day 2, morning"
149
- tags: [mystery, catalyst, solo]
150
- ---
151
-
152
- The envelope had been slipped under the door of the flat she no longer lived in. The landlord had kept it for her -- "figured you'd be back eventually," he said, in a tone that suggested he had not figured this at all.
153
-
154
- Her name was on the front in handwriting she didn't recognize. Inside was a single card with an address across town and a time: 9 p.m., two days from now.
155
-
156
- No name. No explanation.
157
-
158
- She turned the card over. On the back, in smaller writing: *You know what happened to your father. We do too.*
159
-
160
- Elena Voss sat down on the floor of the empty flat and stared at the card for a long time.
161
- `
162
- );
163
-
164
- writeFileSyncWithDirs(
165
- path.join(syncDir, "projects", "test-novel", "part-1", "chapter-2", "sc-003.meta.yaml"),
166
- `scene_id: sc-003
167
- title: The Offer
168
- part: 1
169
- chapter: 2
170
- characters:
171
- - elena
172
- places:
173
- - harbor-district
174
- logline: >-
175
- Elena receives an envelope at her old address -- an offer she doesn't
176
- understand yet, but can't ignore.
177
- save_the_cat: Catalyst
178
- pov: elena
179
- timeline_position: 3
180
- story_time: 'Day 2, morning'
181
- tags:
182
- - mystery
183
- - catalyst
184
- - solo
185
- `
186
- );
187
-
188
- writeFileSyncWithDirs(
189
- path.join(syncDir, "projects", "test-novel", "world", "characters", "elena.md"),
190
- `---
191
- character_id: elena
192
- name: Elena Voss
193
- role: protagonist
194
- traits: [driven, guarded, perceptive, self-sabotaging]
195
- arc_summary: Learns to trust others without losing herself.
196
- first_appearance: sc-001
197
- tags: [main-cast]
198
- ---
199
-
200
- Elena grew up in the harbor district, the daughter of a dockworker who disappeared when she was twelve. She has spent most of her adult life building walls and calling it independence. Perceptive to a fault -- she sees through people quickly, which makes her both valuable and exhausting to be around.
201
-
202
- Her self-sabotaging streak shows up most clearly in relationships. When things get close, she finds a reason to leave first.
203
- `
204
- );
205
-
206
- writeFileSyncWithDirs(
207
- path.join(syncDir, "projects", "test-novel", "world", "characters", "elena.meta.yaml"),
208
- `character_id: elena
209
- name: Elena Voss
210
- role: protagonist
211
- traits:
212
- - driven
213
- - guarded
214
- - perceptive
215
- - self-sabotaging
216
- arc_summary: Learns to trust others without losing herself.
217
- first_appearance: sc-001
218
- tags:
219
- - main-cast
220
- `
221
- );
222
-
223
- writeFileSyncWithDirs(
224
- path.join(syncDir, "projects", "test-novel", "world", "characters", "marcus.md"),
225
- `---
226
- character_id: marcus
227
- name: Marcus Hale
228
- role: supporting
229
- traits: [patient, idealistic, stubborn, warm]
230
- arc_summary: Has to decide whether loyalty to Elena is worth the cost to himself.
231
- first_appearance: sc-001
232
- tags: [main-cast]
233
- ---
234
-
235
- Marcus runs a small freight operation out of the harbor. He has known Elena since they were teenagers and is one of the few people she has never fully pushed away -- not for lack of trying on her part.
236
-
237
- He is patient in a way that sometimes reads as passive. He is not passive. He is waiting for the right moment, which he has been doing for approximately fifteen years.
238
- `
239
- );
240
-
241
- writeFileSyncWithDirs(
242
- path.join(syncDir, "projects", "test-novel", "world", "characters", "marcus.meta.yaml"),
243
- `character_id: marcus
244
- name: Marcus Hale
245
- role: supporting
246
- traits:
247
- - patient
248
- - idealistic
249
- - stubborn
250
- - warm
251
- arc_summary: Has to decide whether loyalty to Elena is worth the cost to himself.
252
- first_appearance: sc-001
253
- tags:
254
- - main-cast
255
- `
256
- );
257
-
258
- writeFileSyncWithDirs(
259
- path.join(syncDir, "projects", "test-novel", "world", "places", "harbor-district.md"),
260
- `---
261
- place_id: harbor-district
262
- name: The Harbor District
263
- associated_characters: [elena, marcus]
264
- tags: [urban, working-class, recurring]
265
- ---
266
-
267
- The harbor district is loud and smells of brine and diesel. The buildings closest to the water are old enough to have survived two floods and a fire. Most of the businesses that used to operate here have moved inland; the ones that remain are either too stubborn or too poor to follow.
268
-
269
- It is the kind of place people are from, not the kind of place people choose.
270
- `
271
- );
272
-
273
- writeFileSyncWithDirs(
274
- path.join(syncDir, "projects", "test-novel", "world", "places", "harbor-district.meta.yaml"),
275
- `place_id: harbor-district
276
- name: The Harbor District
277
- associated_characters:
278
- - elena
279
- - marcus
280
- tags:
281
- - urban
282
- - working-class
283
- - recurring
284
- `
285
- );
286
- }
287
-
288
- export function createScrivenerDraftFixture(baseDir) {
289
- const draftDir = path.join(baseDir, "Draft");
290
- fs.mkdirSync(draftDir, { recursive: true });
291
-
292
- fs.writeFileSync(
293
- path.join(draftDir, "001 Scene Arrival [10].txt"),
294
- "Elena arrives at the station and scans for familiar faces.\n",
295
- "utf8"
296
- );
297
-
298
- fs.writeFileSync(path.join(draftDir, "002 -Setup- [11].txt"), "", "utf8");
299
-
300
- fs.writeFileSync(
301
- path.join(draftDir, "003 Epigraph [12].txt"),
302
- "A city remembers what its people forget.\n",
303
- "utf8"
304
- );
305
-
306
- fs.writeFileSync(
307
- path.join(draftDir, "004 Scene Debate [13].txt"),
308
- "Marcus challenges Elena's plan in the stairwell.\n",
309
- "utf8"
310
- );
311
-
312
- fs.writeFileSync(path.join(draftDir, "005 Chapter Card [14].txt"), "", "utf8");
313
- fs.writeFileSync(path.join(draftDir, "006 Notes.txt"), "Not in expected filename format.\n", "utf8");
314
- }
315
-
316
- export function createScrivenerProjectBundleFixture(baseDir) {
317
- const scrivDir = path.join(baseDir, "Sebastian the Vampire.scriv");
318
- const scrivxPath = path.join(scrivDir, "Sebastian the Vampire.scrivx");
319
- fs.mkdirSync(path.join(scrivDir, "Files", "Data", "UUID-10"), { recursive: true });
320
- fs.mkdirSync(path.join(scrivDir, "Files", "Data", "UUID-13"), { recursive: true });
321
-
322
- fs.writeFileSync(
323
- path.join(scrivDir, "Files", "Data", "UUID-10", "synopsis.txt"),
324
- "Elena arrives at the station and scans for familiar faces.\n",
325
- "utf8"
326
- );
327
- fs.writeFileSync(
328
- path.join(scrivDir, "Files", "Data", "UUID-13", "synopsis.txt"),
329
- "Marcus challenges Elena's plan in the stairwell.\n",
330
- "utf8"
331
- );
332
-
333
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
334
- <ScrivenerProject>
335
- <ExternalSyncMap>
336
- <SyncItem ID="UUID-10">10</SyncItem>
337
- <SyncItem ID="UUID-13">13</SyncItem>
338
- </ExternalSyncMap>
339
- <Keywords>
340
- <Keyword ID="kw-elena"><Title>Elena Voss</Title></Keyword>
341
- <Keyword ID="kw-version"><Title>v1.1</Title></Keyword>
342
- </Keywords>
343
- <Binder>
344
- <BinderItem Type="DraftFolder" UUID="draft-root">
345
- <Children>
346
- <BinderItem Type="Folder" UUID="part-1">
347
- <Title>Part One</Title>
348
- <Children>
349
- <BinderItem Type="Folder" UUID="chapter-1">
350
- <Title>Arrival</Title>
351
- <Children>
352
- <BinderItem Type="Text" UUID="UUID-10">
353
- <Keywords>
354
- <KeywordID>kw-elena</KeywordID>
355
- <KeywordID>kw-version</KeywordID>
356
- </Keywords>
357
- <MetaData>
358
- <MetaDataItem><FieldID>savethecat!</FieldID><Value>Setup</Value></MetaDataItem>
359
- <MetaDataItem><FieldID>causality</FieldID><Value>2</Value></MetaDataItem>
360
- <MetaDataItem><FieldID>f:character</FieldID><Value>Yes</Value></MetaDataItem>
361
- </MetaData>
362
- </BinderItem>
363
- <BinderItem Type="Text" UUID="UUID-13">
364
- <MetaData>
365
- <MetaDataItem><FieldID>stakes</FieldID><Value>3</Value></MetaDataItem>
366
- </MetaData>
367
- </BinderItem>
368
- </Children>
369
- </BinderItem>
370
- </Children>
371
- </BinderItem>
372
- </Children>
373
- </BinderItem>
374
- </Binder>
375
- </ScrivenerProject>`;
376
-
377
- fs.mkdirSync(scrivDir, { recursive: true });
378
- fs.writeFileSync(scrivxPath, xml, "utf8");
379
- return scrivDir;
380
- }
@@ -1,137 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { fileURLToPath } from "node:url";
3
- import path from "node:path";
4
- import fs from "node:fs";
5
- import os from "node:os";
6
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
8
- import { createTestSyncFixture, copyDirSync } from "./fixtures.js";
9
-
10
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const ROOT = path.resolve(__dirname, "../..");
12
-
13
- export function spawnServer(port, syncDir, extraEnv = {}) {
14
- const proc = spawn(
15
- process.execPath,
16
- ["--experimental-sqlite", path.join(ROOT, "index.js")],
17
- {
18
- env: {
19
- ...process.env,
20
- WRITING_SYNC_DIR: syncDir,
21
- DB_PATH: ":memory:",
22
- HTTP_PORT: String(port),
23
- ...extraEnv,
24
- },
25
- stdio: ["ignore", "ignore", "pipe"],
26
- }
27
- );
28
- proc.on("error", (err) => {
29
- throw new Error(`Failed to start server: ${err.message}`);
30
- });
31
- return proc;
32
- }
33
-
34
- export async function waitForServer(url, proc = null, retries = 20, delayMs = 200) {
35
- let stderr = "";
36
- if (proc?.stderr) {
37
- proc.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); });
38
- }
39
- for (let i = 0; i < retries; i++) {
40
- try {
41
- const res = await fetch(`${url}/healthz`);
42
- if (res.ok) return;
43
- } catch {}
44
- await new Promise((r) => setTimeout(r, delayMs));
45
- }
46
- const hint = stderr.trim() ? `\nServer stderr:\n${stderr.trim()}` : "";
47
- throw new Error(`Server at ${url} did not become ready${hint}`);
48
- }
49
-
50
- export async function waitForExit(proc, timeoutMs = 5000) {
51
- return await new Promise((resolve, reject) => {
52
- const timeout = setTimeout(
53
- () => reject(new Error("Process did not exit in time")),
54
- timeoutMs
55
- );
56
- proc.once("exit", (code, signal) => {
57
- clearTimeout(timeout);
58
- resolve({ code, signal });
59
- });
60
- });
61
- }
62
-
63
- export async function connectClient(url) {
64
- const c = new Client({ name: "integration-test-client", version: "1.0.0" });
65
- const transport = new SSEClientTransport(new URL(`${url}/sse`));
66
- await c.connect(transport);
67
- return c;
68
- }
69
-
70
- /**
71
- * Creates a self-contained server context for integration test files.
72
- * Each file gets its own read-only and writable server pair.
73
- *
74
- * Usage:
75
- * const ctx = createTestContext(3079, 3078);
76
- * before(() => ctx.setup());
77
- * after(() => ctx.teardown());
78
- * const callTool = (n, a) => ctx.callTool(n, a);
79
- */
80
- export function createTestContext(readPort, writePort, extraEnv = {}) {
81
- let serverProc, writeServerProc, client, writeClient;
82
- let readSyncDir, writeSyncDir;
83
-
84
- const ctx = {
85
- get readSyncDir() { return readSyncDir; },
86
- get writeSyncDir() { return writeSyncDir; },
87
- get client() { return client; },
88
- get writeClient() { return writeClient; },
89
-
90
- async setup() {
91
- readSyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-writing-read-"));
92
- createTestSyncFixture(readSyncDir);
93
- serverProc = spawnServer(readPort, readSyncDir, { DEFAULT_METADATA_PAGE_SIZE: "2", ...extraEnv });
94
- await waitForServer(`http://localhost:${readPort}`, serverProc);
95
- client = await connectClient(`http://localhost:${readPort}`);
96
-
97
- writeSyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-writing-write-"));
98
- copyDirSync(readSyncDir, writeSyncDir);
99
- writeServerProc = spawnServer(writePort, writeSyncDir, { DEFAULT_METADATA_PAGE_SIZE: "2", ...extraEnv });
100
- await waitForServer(`http://localhost:${writePort}`, writeServerProc);
101
- writeClient = await connectClient(`http://localhost:${writePort}`);
102
- },
103
-
104
- async teardown() {
105
- if (client) try { await client.close(); } catch {}
106
- if (writeClient) try { await writeClient.close(); } catch {}
107
- if (serverProc) serverProc.kill();
108
- if (writeServerProc) writeServerProc.kill();
109
- if (readSyncDir) fs.rmSync(readSyncDir, { recursive: true, force: true });
110
- if (writeSyncDir) fs.rmSync(writeSyncDir, { recursive: true, force: true });
111
- },
112
-
113
- async callTool(name, args = {}) {
114
- const result = await client.callTool({ name, arguments: args });
115
- return result.content?.[0]?.text ?? "";
116
- },
117
-
118
- async callWriteTool(name, args = {}) {
119
- const result = await writeClient.callTool({ name, arguments: args });
120
- return result.content?.[0]?.text ?? "";
121
- },
122
-
123
- async waitForAsyncJob(jobId, timeoutMs = 12000) {
124
- const start = Date.now();
125
- while (Date.now() - start < timeoutMs) {
126
- const text = await ctx.callWriteTool("get_async_job_status", { job_id: jobId });
127
- const parsed = JSON.parse(text);
128
- const status = parsed.job?.status;
129
- if (status === "completed" || status === "failed" || status === "cancelled") return parsed;
130
- await new Promise((r) => setTimeout(r, 100));
131
- }
132
- throw new Error(`Timed out waiting for async job ${jobId}`);
133
- },
134
- };
135
-
136
- return ctx;
137
- }