@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 +10 -0
- package/package.json +11 -2
- package/src/test/helpers/db.js +0 -47
- package/src/test/helpers/fixtures.js +0 -380
- package/src/test/helpers/server.js +0 -137
- package/src/test/integration/editing.test.mjs +0 -265
- package/src/test/integration/metadata.test.mjs +0 -320
- package/src/test/integration/review-bundles.test.mjs +0 -305
- package/src/test/integration/runtime.test.mjs +0 -366
- package/src/test/integration/search.test.mjs +0 -383
- package/src/test/integration/stdio-cli.test.mjs +0 -46
- package/src/test/integration/styleguide.test.mjs +0 -465
- package/src/test/integration/sync.test.mjs +0 -1021
- package/src/test/unit/db.test.mjs +0 -284
- package/src/test/unit/importer.test.mjs +0 -1455
- package/src/test/unit/metadata-lint.test.mjs +0 -295
- package/src/test/unit/registry-metadata.test.mjs +0 -19
- package/src/test/unit/review-bundles.test.mjs +0 -366
- package/src/test/unit/scene-character.test.mjs +0 -392
- package/src/test/unit/styleguide.test.mjs +0 -473
- package/src/test/unit/sync.test.mjs +0 -769
- package/src/test/unit/validation.test.mjs +0 -92
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.
|
|
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
|
],
|
package/src/test/helpers/db.js
DELETED
|
@@ -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
|
-
}
|