@hanna84/mcp-writing 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/index.js +4 -5
- package/metadata-lint.js +4 -2
- package/package.json +9 -2
- package/scripts/import.js +88 -25
- package/scripts/manual-validation.mjs +273 -0
- package/scripts/mcp-debug-client.mjs +43 -0
- package/sync.js +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.1.1](https://github.com/hannasdev/mcp-writing/compare/v1.1.0...v1.1.1) (2026-04-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* use dedicated token for release-please workflow ([#10](https://github.com/hannasdev/mcp-writing/issues/10)) ([6167a59](https://github.com/hannasdev/mcp-writing/commit/6167a598999c173ba04bcdc61223259449e1cf24))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Miscellaneous Chores
|
|
12
|
+
|
|
13
|
+
* fix lint in MCP validation scripts ([2b2385e](https://github.com/hannasdev/mcp-writing/commit/2b2385e3eddd8fd30716d0f72dfa6346ec2d6e5f))
|
|
14
|
+
* include chore commits in release automation ([5a28f27](https://github.com/hannasdev/mcp-writing/commit/5a28f277d1551e35c7fd717836c259badbf8a1d9))
|
|
15
|
+
* include chore commits in release-please ([c437850](https://github.com/hannasdev/mcp-writing/commit/c4378501ee7dc29d582ce78bc1d227927e719810))
|
|
16
|
+
* keep reusable MCP validation scripts ([65f44cb](https://github.com/hannasdev/mcp-writing/commit/65f44cb730176aa4b340c42685dccda05a6725e6))
|
|
17
|
+
* keep reusable MCP validation scripts ([9d4181e](https://github.com/hannasdev/mcp-writing/commit/9d4181e32d6ba9a76a33659c5cfedfe9cf296619))
|
|
18
|
+
* trigger CI for release-please branches ([09e599a](https://github.com/hannasdev/mcp-writing/commit/09e599a399e6b9e039ef2670cfa51a88518dbd56))
|
|
19
|
+
|
|
20
|
+
## [1.1.0](https://github.com/hannasdev/mcp-writing/compare/v1.0.0...v1.1.0) (2026-04-16)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* reconcile Scrivener reorders via stable external IDs ([977a1c3](https://github.com/hannasdev/mcp-writing/commit/977a1c3770861b5f8b0dd9ec8bcdcb00ff39d18a))
|
|
26
|
+
* stable Scrivener identity and reorder reconciliation ([14c165f](https://github.com/hannasdev/mcp-writing/commit/14c165fd80050c59e162fd35bd84cbd39b767cee))
|
package/index.js
CHANGED
|
@@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import fs from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
5
|
import matter from "gray-matter";
|
|
7
6
|
import { z } from "zod";
|
|
8
7
|
import { openDb } from "./db.js";
|
|
@@ -371,7 +370,7 @@ function createMcpServer() {
|
|
|
371
370
|
const raw = fs.readFileSync(character.file_path, "utf8");
|
|
372
371
|
const { content } = matter(raw);
|
|
373
372
|
notes = content.trim();
|
|
374
|
-
} catch {}
|
|
373
|
+
} catch { /* empty */ }
|
|
375
374
|
}
|
|
376
375
|
|
|
377
376
|
const result = { ...character, traits, notes: notes || undefined };
|
|
@@ -779,7 +778,7 @@ function createMcpServer() {
|
|
|
779
778
|
{
|
|
780
779
|
scene_id: z.string().describe("The scene_id to flag (e.g. 'sc-012-open-to-anyone')."),
|
|
781
780
|
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\
|
|
781
|
+
note: z.string().describe("The flag note (e.g. 'Victor knows Mira\u2019s name here, but they haven\u2019t been introduced yet \u2014 contradicts sc-006')."),
|
|
783
782
|
},
|
|
784
783
|
async ({ scene_id, project_id, note }) => {
|
|
785
784
|
if (!SYNC_DIR_WRITABLE) {
|
|
@@ -850,8 +849,8 @@ const httpServer = http.createServer(async (req, res) => {
|
|
|
850
849
|
|
|
851
850
|
const existing = activeSessions.get(sessionId);
|
|
852
851
|
if (existing) {
|
|
853
|
-
try { await existing.transport.close(); } catch {}
|
|
854
|
-
try { await existing.server.close(); } catch {}
|
|
852
|
+
try { await existing.transport.close(); } catch { /* empty */ }
|
|
853
|
+
try { await existing.server.close(); } catch { /* empty */ }
|
|
855
854
|
activeSessions.delete(sessionId);
|
|
856
855
|
}
|
|
857
856
|
|
package/metadata-lint.js
CHANGED
|
@@ -17,6 +17,8 @@ const threadLinkSchema = z.object({
|
|
|
17
17
|
|
|
18
18
|
const sceneSchema = z.object({
|
|
19
19
|
scene_id: z.string().min(1),
|
|
20
|
+
external_source: z.string().min(1).optional(),
|
|
21
|
+
external_id: z.string().min(1).optional(),
|
|
20
22
|
title: z.string().min(1).optional(),
|
|
21
23
|
part: z.number().int().positive().optional(),
|
|
22
24
|
chapter: z.number().int().positive().optional(),
|
|
@@ -284,7 +286,7 @@ export function lintMetadataInSyncDir(syncDir) {
|
|
|
284
286
|
arr.push(sidecar);
|
|
285
287
|
sceneIdToFiles.set(meta.scene_id, arr);
|
|
286
288
|
}
|
|
287
|
-
} catch {}
|
|
289
|
+
} catch { /* empty */ }
|
|
288
290
|
}
|
|
289
291
|
for (const file of files) {
|
|
290
292
|
if (fs.existsSync(sidecarPath(file))) continue; // already counted via sidecar
|
|
@@ -295,7 +297,7 @@ export function lintMetadataInSyncDir(syncDir) {
|
|
|
295
297
|
arr.push(file);
|
|
296
298
|
sceneIdToFiles.set(data.scene_id, arr);
|
|
297
299
|
}
|
|
298
|
-
} catch {}
|
|
300
|
+
} catch { /* empty */ }
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
for (const [sceneId, dupeFiles] of sceneIdToFiles) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "MCP service for AI-assisted reasoning and editing on long-form fiction projects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
"sync.js",
|
|
11
11
|
"metadata-lint.js",
|
|
12
12
|
"scripts/",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
14
15
|
],
|
|
15
16
|
"publishConfig": {
|
|
16
17
|
"access": "public"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"start": "node --experimental-sqlite index.js",
|
|
21
|
+
"lint": "eslint index.js db.js sync.js metadata-lint.js scripts/",
|
|
20
22
|
"lint:metadata": "node scripts/lint-metadata.mjs",
|
|
21
23
|
"lint:metadata:test": "node scripts/lint-metadata.mjs --sync-dir ./test-sync",
|
|
22
24
|
"test:unit": "node --experimental-sqlite --test test/unit.test.mjs",
|
|
@@ -38,5 +40,10 @@
|
|
|
38
40
|
"gray-matter": "^4.0.3",
|
|
39
41
|
"js-yaml": "^4.1.1",
|
|
40
42
|
"zod": "^4.3.6"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^10.0.1",
|
|
46
|
+
"eslint": "^10.2.0",
|
|
47
|
+
"globals": "^17.5.0"
|
|
41
48
|
}
|
|
42
49
|
}
|
package/scripts/import.js
CHANGED
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
* --dry-run Show what would be created without writing anything
|
|
15
15
|
*
|
|
16
16
|
* What it does (Draft folder):
|
|
17
|
-
* - Walks the Draft dir in filename order (NNN prefix = binder sequence)
|
|
17
|
+
* - Walks the Draft dir in filename order (NNN prefix = current binder sequence)
|
|
18
18
|
* - Skips empty files (non-compilation title cards) and Epigraphs
|
|
19
19
|
* - Detects Save the Cat beat markers ("-Beat Name-" empty files) and carries
|
|
20
20
|
* the beat name forward to the next prose scene's sidecar
|
|
21
21
|
* - Creates mcp-sync-dir/projects/<project>/scenes/ structure
|
|
22
|
-
* -
|
|
22
|
+
* - Reconciles existing imports by stable Scrivener binder ID (`[123]` in the filename)
|
|
23
|
+
* - Writes a .meta.yaml sidecar for each scene while preserving existing editorial metadata
|
|
23
24
|
*
|
|
24
25
|
* What it does (Notes folder):
|
|
25
26
|
* - Tracks section mode via empty top-level folder markers (Characters, Places, World...)
|
|
@@ -62,11 +63,16 @@ const placesDir = path.join(mcpSyncDir, "projects", projectId, "world", "places"
|
|
|
62
63
|
// Helpers
|
|
63
64
|
// ---------------------------------------------------------------------------
|
|
64
65
|
|
|
65
|
-
// Parse "NNN Title [binder_id].txt" → { seq, rawTitle } or null
|
|
66
|
+
// Parse "NNN Title [binder_id].txt" → { seq, rawTitle, binderId, ext } or null
|
|
66
67
|
function parseFilename(filename) {
|
|
67
|
-
const m = filename.match(/^(\d+)\s+(.+?)\s*\[\d
|
|
68
|
+
const m = filename.match(/^(\d+)\s+(.+?)\s*\[(\d+)\]\.(txt|md)$/);
|
|
68
69
|
if (!m) return null;
|
|
69
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
seq: parseInt(m[1], 10),
|
|
72
|
+
rawTitle: m[2].trim(),
|
|
73
|
+
binderId: m[3],
|
|
74
|
+
ext: m[4],
|
|
75
|
+
};
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
function isBeatMarker(rawTitle) {
|
|
@@ -94,8 +100,8 @@ function slugify(str) {
|
|
|
94
100
|
.slice(0, 50);
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
function makeSceneId(
|
|
98
|
-
return `sc-${String(
|
|
103
|
+
function makeSceneId(binderId, title) {
|
|
104
|
+
return `sc-${String(binderId).padStart(3, "0")}-${slugify(title).slice(0, 40)}`;
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
function makeCharacterId(rawTitle) {
|
|
@@ -137,6 +143,51 @@ function walkSorted(dir) {
|
|
|
137
143
|
return files.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
138
144
|
}
|
|
139
145
|
|
|
146
|
+
function loadYamlFile(filePath) {
|
|
147
|
+
try {
|
|
148
|
+
return yaml.load(fs.readFileSync(filePath, "utf8")) ?? {};
|
|
149
|
+
} catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildExistingSceneIndex(dir) {
|
|
155
|
+
const byBinderId = new Map();
|
|
156
|
+
if (!fs.existsSync(dir)) return byBinderId;
|
|
157
|
+
|
|
158
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
159
|
+
if (!entry.isFile() || !entry.name.endsWith(".meta.yaml")) continue;
|
|
160
|
+
|
|
161
|
+
const sidecarPath = path.join(dir, entry.name);
|
|
162
|
+
const proseCandidates = [
|
|
163
|
+
sidecarPath.replace(/\.meta\.yaml$/, ".txt"),
|
|
164
|
+
sidecarPath.replace(/\.meta\.yaml$/, ".md"),
|
|
165
|
+
];
|
|
166
|
+
const prosePath = proseCandidates.find(candidate => fs.existsSync(candidate)) ?? null;
|
|
167
|
+
const proseName = prosePath ? path.basename(prosePath) : entry.name.replace(/\.meta\.yaml$/, ".txt");
|
|
168
|
+
const parsedName = parseFilename(proseName);
|
|
169
|
+
const meta = loadYamlFile(sidecarPath);
|
|
170
|
+
const binderId = meta.external_source === "scrivener" && meta.external_id
|
|
171
|
+
? String(meta.external_id)
|
|
172
|
+
: parsedName?.binderId ?? null;
|
|
173
|
+
|
|
174
|
+
if (!binderId) continue;
|
|
175
|
+
|
|
176
|
+
byBinderId.set(String(binderId), {
|
|
177
|
+
binderId: String(binderId),
|
|
178
|
+
prosePath,
|
|
179
|
+
sidecarPath,
|
|
180
|
+
meta,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return byBinderId;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function removeIfExists(filePath) {
|
|
188
|
+
if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
189
|
+
}
|
|
190
|
+
|
|
140
191
|
// ---------------------------------------------------------------------------
|
|
141
192
|
// Main
|
|
142
193
|
// ---------------------------------------------------------------------------
|
|
@@ -149,6 +200,7 @@ const hasNotes = fs.existsSync(notesDir);
|
|
|
149
200
|
const draftRoot = hasDraft ? draftDir : scrivenerDir;
|
|
150
201
|
|
|
151
202
|
const files = walkSorted(draftRoot);
|
|
203
|
+
const existingScenes = buildExistingSceneIndex(scenesDir);
|
|
152
204
|
let created = 0;
|
|
153
205
|
let skipped = 0;
|
|
154
206
|
let alreadyDone = 0;
|
|
@@ -172,7 +224,7 @@ for (const file of files) {
|
|
|
172
224
|
continue;
|
|
173
225
|
}
|
|
174
226
|
|
|
175
|
-
const { seq, rawTitle } = parsed;
|
|
227
|
+
const { seq, rawTitle, binderId, ext } = parsed;
|
|
176
228
|
const isEmpty = fs.statSync(file).size === 0;
|
|
177
229
|
|
|
178
230
|
// Beat markers: always empty, carry beat name forward
|
|
@@ -197,20 +249,17 @@ for (const file of files) {
|
|
|
197
249
|
}
|
|
198
250
|
|
|
199
251
|
// Scene file — create sidecar
|
|
200
|
-
const title
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
if (fs.existsSync(sidecar)) {
|
|
206
|
-
console.log(` SKIP (sidecar exists) ${filename}`);
|
|
207
|
-
alreadyDone++;
|
|
208
|
-
beatCarry = null; // beat was consumed by an existing scene
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
252
|
+
const title = cleanTitle(rawTitle);
|
|
253
|
+
const existing = existingScenes.get(String(binderId)) ?? null;
|
|
254
|
+
const sceneId = existing?.meta?.scene_id ?? makeSceneId(binderId, title);
|
|
255
|
+
const destFile = path.join(scenesDir, `${seq.toString().padStart(3, "0")} ${rawTitle} [${binderId}].${ext}`);
|
|
256
|
+
const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
|
|
211
257
|
|
|
212
258
|
const meta = {
|
|
259
|
+
...(existing?.meta ?? {}),
|
|
213
260
|
scene_id: sceneId,
|
|
261
|
+
external_source: "scrivener",
|
|
262
|
+
external_id: String(binderId),
|
|
214
263
|
title,
|
|
215
264
|
timeline_position: seq,
|
|
216
265
|
...(beatCarry ? { save_the_cat_beat: beatCarry } : {}),
|
|
@@ -224,20 +273,34 @@ for (const file of files) {
|
|
|
224
273
|
// tags: [],
|
|
225
274
|
};
|
|
226
275
|
|
|
276
|
+
if (!beatCarry && existing?.meta && Object.hasOwn(existing.meta, "save_the_cat_beat")) {
|
|
277
|
+
delete meta.save_the_cat_beat;
|
|
278
|
+
}
|
|
279
|
+
|
|
227
280
|
if (dryRun) {
|
|
228
281
|
console.log(` DRY ${path.basename(sidecar)}`);
|
|
282
|
+
if (existing) {
|
|
283
|
+
console.log(` reconcile: binder ${binderId} -> existing scene_id ${sceneId}`);
|
|
284
|
+
}
|
|
229
285
|
console.log(` scene_id: ${sceneId}, beat: ${beatCarry ?? "(none)"}`);
|
|
230
286
|
} else {
|
|
231
|
-
// Copy prose file
|
|
232
|
-
|
|
233
|
-
fs.copyFileSync(file, destFile);
|
|
234
|
-
}
|
|
287
|
+
// Copy/update prose file in mcp-sync-dir scenes folder
|
|
288
|
+
fs.copyFileSync(file, destFile);
|
|
235
289
|
fs.writeFileSync(sidecar, yaml.dump(meta, { lineWidth: 120 }), "utf8");
|
|
236
|
-
|
|
290
|
+
|
|
291
|
+
if (existing) {
|
|
292
|
+
if (existing.prosePath && existing.prosePath !== destFile) removeIfExists(existing.prosePath);
|
|
293
|
+
if (existing.sidecarPath && existing.sidecarPath !== sidecar) removeIfExists(existing.sidecarPath);
|
|
294
|
+
console.log(` OK ${path.basename(sidecar)} [reconciled binder ${binderId}, beat: ${beatCarry ?? "—"}]`);
|
|
295
|
+
} else {
|
|
296
|
+
console.log(` OK ${path.basename(sidecar)} [beat: ${beatCarry ?? "—"}]`);
|
|
297
|
+
}
|
|
298
|
+
existingScenes.set(String(binderId), { binderId: String(binderId), prosePath: destFile, sidecarPath: sidecar, meta });
|
|
237
299
|
}
|
|
238
300
|
|
|
239
301
|
beatCarry = null; // consumed
|
|
240
|
-
|
|
302
|
+
if (existing) alreadyDone++;
|
|
303
|
+
else created++;
|
|
241
304
|
}
|
|
242
305
|
|
|
243
306
|
console.log(`\n${"─".repeat(50)}`);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Manual Validation Script - Fixed Version
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
7
|
+
import { URL as NodeURL } from "node:url";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
const ROOT = process.cwd();
|
|
12
|
+
|
|
13
|
+
async function waitForServer(url, retries = 30, delayMs = 300) {
|
|
14
|
+
for (let i = 0; i < retries; i++) {
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(`${url}/healthz`);
|
|
17
|
+
if (res.ok) return true;
|
|
18
|
+
} catch { /* empty */ }
|
|
19
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Server did not become ready`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function spawnServer(port, syncDir) {
|
|
25
|
+
const proc = spawn(process.execPath, ["--experimental-sqlite", `${ROOT}/index.js`], {
|
|
26
|
+
env: { ...process.env, WRITING_SYNC_DIR: syncDir, DB_PATH: ":memory:", HTTP_PORT: String(port) },
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
return proc;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function connectClient(url) {
|
|
33
|
+
const c = new Client({ name: "manual-validation-client", version: "1.0.0" });
|
|
34
|
+
const transport = new SSEClientTransport(new NodeURL(`${url}/sse`));
|
|
35
|
+
await c.connect(transport);
|
|
36
|
+
return c;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function callTool(client, name, args = {}) {
|
|
40
|
+
try {
|
|
41
|
+
return await client.callTool({ name, arguments: args });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { error: e.message };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseResponse(result) {
|
|
48
|
+
if (result.error) return { error: result.error };
|
|
49
|
+
try {
|
|
50
|
+
const text = result.content?.[0]?.text;
|
|
51
|
+
if (!text) return { raw: result };
|
|
52
|
+
// Try to parse as JSON
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(text);
|
|
55
|
+
} catch {
|
|
56
|
+
return { text };
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return { raw: result };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ======================== PHASE A ========================
|
|
64
|
+
async function runPhaseA() {
|
|
65
|
+
console.log("\n========== PHASE A: Raw Export (./txt) ==========\n");
|
|
66
|
+
const PORT = 3110;
|
|
67
|
+
const BASE = `http://localhost:${PORT}`;
|
|
68
|
+
|
|
69
|
+
const proc = spawnServer(PORT, `${ROOT}/txt`);
|
|
70
|
+
const results = { errors: [] };
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await waitForServer(BASE);
|
|
74
|
+
console.log("✓ Server started on port", PORT);
|
|
75
|
+
|
|
76
|
+
const client = await connectClient(BASE);
|
|
77
|
+
console.log("✓ MCP Client connected\n");
|
|
78
|
+
|
|
79
|
+
// sync
|
|
80
|
+
const syncRes = await callTool(client, "sync");
|
|
81
|
+
const syncText = syncRes.content?.[0]?.text || JSON.stringify(syncRes);
|
|
82
|
+
results.syncMessage = syncText;
|
|
83
|
+
console.log("sync:", syncText.slice(0, 200));
|
|
84
|
+
|
|
85
|
+
// find_scenes
|
|
86
|
+
const scenesRes = await callTool(client, "find_scenes", {});
|
|
87
|
+
const scenesData = parseResponse(scenesRes);
|
|
88
|
+
results.sceneCount = scenesData.total_count ?? scenesData.results?.length ?? "N/A";
|
|
89
|
+
console.log("find_scenes count:", results.sceneCount);
|
|
90
|
+
|
|
91
|
+
// list_characters
|
|
92
|
+
const charsRes = await callTool(client, "list_characters", {});
|
|
93
|
+
const charsData = parseResponse(charsRes);
|
|
94
|
+
results.characterCount = charsData.total_count ?? charsData.characters?.length ?? "N/A";
|
|
95
|
+
console.log("list_characters count:", results.characterCount);
|
|
96
|
+
|
|
97
|
+
// list_places
|
|
98
|
+
const placesRes = await callTool(client, "list_places", {});
|
|
99
|
+
const placesData = parseResponse(placesRes);
|
|
100
|
+
results.placeCount = placesData.total_count ?? placesData.places?.length ?? "N/A";
|
|
101
|
+
console.log("list_places count:", results.placeCount);
|
|
102
|
+
|
|
103
|
+
// search_metadata
|
|
104
|
+
const searchRes = await callTool(client, "search_metadata", { query: "airport" });
|
|
105
|
+
const searchData = parseResponse(searchRes);
|
|
106
|
+
results.airportSearchCount = searchData.total_count ?? searchData.results?.length ?? "N/A";
|
|
107
|
+
console.log("search_metadata(airport) count:", results.airportSearchCount);
|
|
108
|
+
|
|
109
|
+
await client.close();
|
|
110
|
+
console.log("✓ Client closed");
|
|
111
|
+
} catch (e) {
|
|
112
|
+
results.errors.push(`Phase A error: ${e.message}`);
|
|
113
|
+
console.error("Phase A error:", e.message);
|
|
114
|
+
} finally {
|
|
115
|
+
proc.kill();
|
|
116
|
+
console.log("✓ Server killed");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ======================== PHASE B ========================
|
|
123
|
+
async function runPhaseB() {
|
|
124
|
+
console.log("\n========== PHASE B: Imported Format ==========\n");
|
|
125
|
+
const PORT = 3111;
|
|
126
|
+
const BASE = `http://localhost:${PORT}`;
|
|
127
|
+
const IMPORT_DIR = "/tmp/mcp-writing-manual";
|
|
128
|
+
|
|
129
|
+
const results = { errors: [], warnings: [] };
|
|
130
|
+
|
|
131
|
+
// Cleanup
|
|
132
|
+
try {
|
|
133
|
+
fs.rmSync(IMPORT_DIR, { recursive: true, force: true });
|
|
134
|
+
console.log("✓ Cleaned up", IMPORT_DIR);
|
|
135
|
+
} catch { /* empty */ }
|
|
136
|
+
|
|
137
|
+
// Import
|
|
138
|
+
try {
|
|
139
|
+
const importOutput = execSync(
|
|
140
|
+
`node scripts/import.js ./txt ${IMPORT_DIR} --project scrivener-export`,
|
|
141
|
+
{ cwd: ROOT, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
142
|
+
);
|
|
143
|
+
console.log("✓ Import completed");
|
|
144
|
+
results.importOutput = importOutput.slice(0, 500);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
results.errors.push(`Import error: ${e.message}`);
|
|
147
|
+
console.error("Import error:", e.message);
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Lint
|
|
152
|
+
try {
|
|
153
|
+
const lintOutput = execSync(
|
|
154
|
+
`node scripts/lint-metadata.mjs --sync-dir ${IMPORT_DIR}`,
|
|
155
|
+
{ cwd: ROOT, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
156
|
+
);
|
|
157
|
+
results.lintOutput = lintOutput;
|
|
158
|
+
console.log("✓ Lint completed");
|
|
159
|
+
console.log("Lint output:\n", lintOutput.slice(0, 400));
|
|
160
|
+
} catch (e) {
|
|
161
|
+
results.lintOutput = e.stdout || e.stderr || e.message;
|
|
162
|
+
results.warnings.push(`Lint warnings: ${results.lintOutput.slice(0, 500)}`);
|
|
163
|
+
console.log("Lint output (non-zero):\n", results.lintOutput.slice(0, 400));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Start server
|
|
167
|
+
const proc = spawnServer(PORT, IMPORT_DIR);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await waitForServer(BASE);
|
|
171
|
+
console.log("✓ Server started on port", PORT);
|
|
172
|
+
|
|
173
|
+
const client = await connectClient(BASE);
|
|
174
|
+
console.log("✓ MCP Client connected\n");
|
|
175
|
+
|
|
176
|
+
// sync
|
|
177
|
+
const syncRes = await callTool(client, "sync");
|
|
178
|
+
const syncText = syncRes.content?.[0]?.text || JSON.stringify(syncRes);
|
|
179
|
+
results.syncMessage = syncText;
|
|
180
|
+
console.log("sync:", syncText.slice(0, 200));
|
|
181
|
+
|
|
182
|
+
// find_scenes with project filter
|
|
183
|
+
const scenesRes = await callTool(client, "find_scenes", {
|
|
184
|
+
project_id: "scrivener-export",
|
|
185
|
+
page_size: 5,
|
|
186
|
+
page: 1
|
|
187
|
+
});
|
|
188
|
+
const scenesData = parseResponse(scenesRes);
|
|
189
|
+
results.sceneCount = scenesData.total_count ?? scenesData.results?.length ?? "N/A";
|
|
190
|
+
results.firstSceneId = scenesData.results?.[0]?.scene_id || null;
|
|
191
|
+
console.log("find_scenes count:", results.sceneCount);
|
|
192
|
+
if (results.firstSceneId) console.log("First scene_id:", results.firstSceneId);
|
|
193
|
+
|
|
194
|
+
// list_characters with project filter
|
|
195
|
+
const charsRes = await callTool(client, "list_characters", { project_id: "scrivener-export" });
|
|
196
|
+
const charsData = parseResponse(charsRes);
|
|
197
|
+
results.characterCount = charsData.total_count ?? charsData.characters?.length ?? "N/A";
|
|
198
|
+
console.log("list_characters count:", results.characterCount);
|
|
199
|
+
|
|
200
|
+
// list_places with project filter
|
|
201
|
+
const placesRes = await callTool(client, "list_places", { project_id: "scrivener-export" });
|
|
202
|
+
const placesData = parseResponse(placesRes);
|
|
203
|
+
results.placeCount = placesData.total_count ?? placesData.places?.length ?? "N/A";
|
|
204
|
+
console.log("list_places count:", results.placeCount);
|
|
205
|
+
|
|
206
|
+
// search_metadata
|
|
207
|
+
const searchRes = await callTool(client, "search_metadata", {
|
|
208
|
+
query: "airport",
|
|
209
|
+
page_size: 5,
|
|
210
|
+
page: 1
|
|
211
|
+
});
|
|
212
|
+
const searchData = parseResponse(searchRes);
|
|
213
|
+
results.airportSearchCount = searchData.total_count ?? searchData.results?.length ?? "N/A";
|
|
214
|
+
console.log("search_metadata(airport) count:", results.airportSearchCount);
|
|
215
|
+
|
|
216
|
+
// get_scene_prose if we have a scene ID
|
|
217
|
+
if (results.firstSceneId) {
|
|
218
|
+
const proseRes = await callTool(client, "get_scene_prose", { scene_id: results.firstSceneId });
|
|
219
|
+
const proseData = parseResponse(proseRes);
|
|
220
|
+
results.proseExcerpt = proseData.prose?.slice(0, 200) || proseData.text?.slice(0, 200) || proseRes.content?.[0]?.text?.slice(0, 200) || "(no prose)";
|
|
221
|
+
console.log("get_scene_prose excerpt:", results.proseExcerpt.slice(0, 150) + "...");
|
|
222
|
+
} else {
|
|
223
|
+
results.proseExcerpt = "(no scene_id available)";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await client.close();
|
|
227
|
+
console.log("✓ Client closed");
|
|
228
|
+
} catch (e) {
|
|
229
|
+
results.errors.push(`Phase B error: ${e.message}`);
|
|
230
|
+
console.error("Phase B error:", e.message);
|
|
231
|
+
} finally {
|
|
232
|
+
proc.kill();
|
|
233
|
+
console.log("✓ Server killed");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ======================== MAIN ========================
|
|
240
|
+
async function main() {
|
|
241
|
+
console.log("Starting MCP Manual Validation...\n");
|
|
242
|
+
|
|
243
|
+
const phaseA = await runPhaseA();
|
|
244
|
+
const phaseB = await runPhaseB();
|
|
245
|
+
|
|
246
|
+
console.log("\n========== FINAL SUMMARY ==========\n");
|
|
247
|
+
|
|
248
|
+
console.log("PHASE A (Raw Export ./txt):");
|
|
249
|
+
console.log(" Sync message:", phaseA.syncMessage?.slice(0, 150) || "N/A");
|
|
250
|
+
console.log(" Scene count:", phaseA.sceneCount);
|
|
251
|
+
console.log(" Character count:", phaseA.characterCount);
|
|
252
|
+
console.log(" Place count:", phaseA.placeCount);
|
|
253
|
+
console.log(" Airport search count:", phaseA.airportSearchCount);
|
|
254
|
+
if (phaseA.errors.length) console.log(" Errors:", phaseA.errors);
|
|
255
|
+
|
|
256
|
+
console.log("\nPHASE B (Imported /tmp/mcp-writing-manual):");
|
|
257
|
+
console.log(" Sync message:", phaseB.syncMessage?.slice(0, 150) || "N/A");
|
|
258
|
+
console.log(" Scene count:", phaseB.sceneCount);
|
|
259
|
+
console.log(" Character count:", phaseB.characterCount);
|
|
260
|
+
console.log(" Place count:", phaseB.placeCount);
|
|
261
|
+
console.log(" Airport search count:", phaseB.airportSearchCount);
|
|
262
|
+
console.log(" First scene_id:", phaseB.firstSceneId || "N/A");
|
|
263
|
+
console.log(" Prose excerpt (200 chars):", phaseB.proseExcerpt?.slice(0, 200) || "N/A");
|
|
264
|
+
if (phaseB.warnings.length) console.log(" Lint warnings:", phaseB.warnings.length > 0 ? "Yes (see above)" : "None");
|
|
265
|
+
if (phaseB.errors.length) console.log(" Errors:", phaseB.errors);
|
|
266
|
+
|
|
267
|
+
console.log("\n========== VALIDATION COMPLETE ==========\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
main().catch(e => {
|
|
271
|
+
console.error("Fatal error:", e);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
|
+
import { URL as NodeURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
const ROOT = process.cwd();
|
|
7
|
+
|
|
8
|
+
async function waitForServer(url, retries = 30, delayMs = 300) {
|
|
9
|
+
for (let i = 0; i < retries; i++) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${url}/healthz`);
|
|
12
|
+
if (res.ok) return true;
|
|
13
|
+
} catch { /* empty */ }
|
|
14
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`Server did not become ready`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PORT = 3112;
|
|
20
|
+
const BASE = `http://localhost:${PORT}`;
|
|
21
|
+
const IMPORT_DIR = "/tmp/mcp-writing-manual";
|
|
22
|
+
|
|
23
|
+
const proc = spawn(process.execPath, ["--experimental-sqlite", `${ROOT}/index.js`], {
|
|
24
|
+
env: { ...process.env, WRITING_SYNC_DIR: IMPORT_DIR, DB_PATH: ":memory:", HTTP_PORT: String(PORT) },
|
|
25
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await waitForServer(BASE);
|
|
30
|
+
const client = new Client({ name: "debug-client", version: "1.0.0" });
|
|
31
|
+
const transport = new SSEClientTransport(new NodeURL(`${BASE}/sse`));
|
|
32
|
+
await client.connect(transport);
|
|
33
|
+
|
|
34
|
+
await client.callTool({ name: "sync", arguments: {} });
|
|
35
|
+
|
|
36
|
+
const scenes = await client.callTool({ name: "find_scenes", arguments: { project_id: "scrivener-export", page_size: 3, page: 1 } });
|
|
37
|
+
console.log("=== find_scenes raw response ===");
|
|
38
|
+
console.log(JSON.stringify(scenes, null, 2));
|
|
39
|
+
|
|
40
|
+
await client.close();
|
|
41
|
+
} finally {
|
|
42
|
+
proc.kill();
|
|
43
|
+
}
|
package/sync.js
CHANGED
|
@@ -141,7 +141,7 @@ export function readMeta(filePath, syncDir, { writable = false } = {}) {
|
|
|
141
141
|
try {
|
|
142
142
|
fs.writeFileSync(sidecar, stringifyYaml(normalized.meta), "utf8");
|
|
143
143
|
return { ...normalized, sourceMeta: frontmatter, sidecarGenerated: true };
|
|
144
|
-
} catch {}
|
|
144
|
+
} catch { /* empty */ }
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
return { ...normalized, sourceMeta: frontmatter, sidecarGenerated: false };
|
|
@@ -402,7 +402,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
|
|
|
402
402
|
try {
|
|
403
403
|
const raw = fs.readFileSync(sidecar, "utf8");
|
|
404
404
|
orphanedSceneId = (parseYaml(raw) ?? {}).scene_id ?? null;
|
|
405
|
-
} catch {}
|
|
405
|
+
} catch { /* empty */ }
|
|
406
406
|
|
|
407
407
|
if (orphanedSceneId && indexedSceneIds.has(orphanedSceneId)) {
|
|
408
408
|
warnings.push(
|