@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 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\'s name here, but they haven\'t been introduced yet contradicts sc-006')."),
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.0.0",
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
- * - Writes a .meta.yaml sidecar for each scene (skips files that already have one)
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+\]\.(txt|md)$/);
68
+ const m = filename.match(/^(\d+)\s+(.+?)\s*\[(\d+)\]\.(txt|md)$/);
68
69
  if (!m) return null;
69
- return { seq: parseInt(m[1], 10), rawTitle: m[2].trim() };
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(seq, title) {
98
- return `sc-${String(seq).padStart(3, "0")}-${slugify(title).slice(0, 40)}`;
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 = cleanTitle(rawTitle);
201
- const sceneId = makeSceneId(seq, title);
202
- const destFile = path.join(scenesDir, filename);
203
- const sidecar = destFile.replace(/\.(txt|md)$/, ".meta.yaml");
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 into mcp-sync-dir scenes folder
232
- if (!fs.existsSync(destFile)) {
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
- console.log(` OK ${path.basename(sidecar)} [beat: ${beatCarry ?? "—"}]`);
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
- created++;
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(