@hanna84/mcp-writing 2.18.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ #### [v3.0.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v2.18.1...v3.0.0)
9
+
10
+ - feat!: improve MCP tooling usability and harden scene-id safety [`#165`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/165)
12
+
7
13
  #### [v2.18.1](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v2.18.0...v2.18.1)
9
15
 
16
+ > 1 May 2026
17
+
10
18
  - docs: finalize reference docs PRD status [`#164`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/164)
20
+ - Release 2.18.1 [`ed9d0e7`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/ed9d0e70b6ea17ea22f84d257d71c33d0350fe5f)
12
22
 
13
23
  #### [v2.18.0](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v2.17.1...v2.18.0)
package/README.md CHANGED
@@ -41,6 +41,47 @@ Instead of feeding an entire manuscript to an AI and hoping it fits in the conte
41
41
  | [docs/tools.md](docs/tools.md) | Full tool reference — auto-generated from source |
42
42
  | [docs/development.md](docs/development.md) | Running locally, tests, environment variables, troubleshooting |
43
43
 
44
+ ## Breaking changes
45
+
46
+ ### `describe_workflows` surface redesign
47
+
48
+ `describe_workflows` now exposes an outcome-first, discovery-first workflow map. This is a breaking change if your prompts or automation depend on previous workflow IDs or ordering.
49
+
50
+ Update integrations using this mapping:
51
+
52
+ - `manuscript_exploration` -> `question_driven_discovery` (or `targeted_scene_reading` when the task is prose inspection)
53
+ - `prose_editing` -> `safe_scene_revision`
54
+ - `character_management` -> `character_understanding`
55
+ - `place_management` -> `place_understanding`
56
+ - `review_bundle` -> `review_preparation`
57
+
58
+ New workflow IDs added:
59
+
60
+ - `thread_understanding`
61
+ - `parity_recovery`
62
+
63
+ Styleguide workflows are still available, but no longer positioned as part of the primary daily workflow surface.
64
+
65
+ ### `find_scenes` and `get_arc` response-shape standardization
66
+
67
+ `find_scenes` and `get_arc` now always return structured envelopes, including non-paginated calls.
68
+
69
+ - Envelope fields: `results`, `total_count`.
70
+ - Pagination fields are included when paging is active.
71
+ - `warning` / `next_step` are included when relevant.
72
+
73
+ If your integration previously handled raw arrays for non-paginated calls, update it to parse envelopes consistently.
74
+
75
+ Safe parsing pattern:
76
+
77
+ ```js
78
+ const parsed = JSON.parse(toolText);
79
+ const scenes = parsed.results ?? [];
80
+ const totalCount = parsed.total_count ?? scenes.length;
81
+ const warning = parsed.warning ?? null;
82
+ const nextStep = parsed.next_step ?? null;
83
+ ```
84
+
44
85
  ## Usage scenarios
45
86
 
46
87
  ### 1) Continuity pass before sending chapters to beta readers
@@ -99,5 +140,17 @@ Goal: rebuild scene-to-character links in a controlled way after imported prose
99
140
 
100
141
  Outcome: character-link maintenance becomes a preview-first batch operation instead of a one-off regex script or manual sidecar cleanup.
101
142
 
143
+ ### 6) Post-upgrade recovery after legacy migration warnings
144
+
145
+ Goal: recover index confidence quickly when legacy upgrade warnings indicate ambiguous rows were skipped.
146
+
147
+ 1. Start by checking `get_runtime_config` (or `describe_workflows`) and confirm whether `db_migration_warnings` contains `LEGACY_JOIN_ROWS_SKIPPED`.
148
+ 2. If present, run `sync` immediately to rebuild scene relationships from current sidecars and prose metadata.
149
+ 3. Continue normal discovery (`find_scenes`, `get_arc`, `get_thread_arc`) and watch for stale-metadata warnings.
150
+ 4. When you touch stale scenes, run `enrich_scene(scene_id, project_id)` to recover metadata parity incrementally.
151
+ 5. If many scenes remain stale, switch to `enrich_scene_characters_batch` (dry-run first) for broader catch-up.
152
+
153
+ Outcome: upgrade-related data loss risk becomes an explicit, operator-visible recovery workflow instead of a silent state mismatch.
154
+
102
155
  ## License
103
156
  AGPL-3.0-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "2.18.1",
3
+ "version": "3.0.0",
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",
package/src/core/db.js CHANGED
@@ -1,5 +1,36 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
 
3
+ const dbStartupWarnings = [];
4
+
5
+ function cloneWarningDetails(details) {
6
+ if (!details) return details;
7
+
8
+ if (typeof structuredClone === "function") {
9
+ try {
10
+ return structuredClone(details);
11
+ } catch {
12
+ // Fall through to JSON clone for plain data payloads.
13
+ }
14
+ }
15
+
16
+ try {
17
+ return JSON.parse(JSON.stringify(details));
18
+ } catch {
19
+ return { ...details };
20
+ }
21
+ }
22
+
23
+ export function getDbStartupWarnings() {
24
+ return dbStartupWarnings.map((warning) => ({
25
+ ...warning,
26
+ details: cloneWarningDetails(warning.details),
27
+ }));
28
+ }
29
+
30
+ function resetDbStartupWarnings() {
31
+ dbStartupWarnings.length = 0;
32
+ }
33
+
3
34
  export const SCHEMA = `
4
35
  CREATE TABLE IF NOT EXISTS universes (
5
36
  universe_id TEXT PRIMARY KEY,
@@ -38,27 +69,31 @@ export const SCHEMA = `
38
69
 
39
70
  CREATE TABLE IF NOT EXISTS scene_characters (
40
71
  scene_id TEXT NOT NULL,
72
+ project_id TEXT NOT NULL,
41
73
  character_id TEXT NOT NULL,
42
- PRIMARY KEY (scene_id, character_id)
74
+ PRIMARY KEY (scene_id, project_id, character_id)
43
75
  );
44
76
 
45
77
  CREATE TABLE IF NOT EXISTS scene_places (
46
- scene_id TEXT NOT NULL,
47
- place_id TEXT NOT NULL,
48
- PRIMARY KEY (scene_id, place_id)
78
+ scene_id TEXT NOT NULL,
79
+ project_id TEXT NOT NULL,
80
+ place_id TEXT NOT NULL,
81
+ PRIMARY KEY (scene_id, project_id, place_id)
49
82
  );
50
83
 
51
84
  CREATE TABLE IF NOT EXISTS scene_tags (
52
- scene_id TEXT NOT NULL,
53
- tag TEXT NOT NULL,
54
- PRIMARY KEY (scene_id, tag)
85
+ scene_id TEXT NOT NULL,
86
+ project_id TEXT NOT NULL,
87
+ tag TEXT NOT NULL,
88
+ PRIMARY KEY (scene_id, project_id, tag)
55
89
  );
56
90
 
57
91
  CREATE TABLE IF NOT EXISTS scene_threads (
58
- scene_id TEXT NOT NULL,
59
- thread_id TEXT NOT NULL,
60
- beat TEXT,
61
- PRIMARY KEY (scene_id, thread_id)
92
+ scene_id TEXT NOT NULL,
93
+ project_id TEXT NOT NULL,
94
+ thread_id TEXT NOT NULL,
95
+ beat TEXT,
96
+ PRIMARY KEY (scene_id, project_id, thread_id)
62
97
  );
63
98
 
64
99
  CREATE TABLE IF NOT EXISTS characters (
@@ -160,6 +195,33 @@ export const SCHEMA = `
160
195
  // Each migration runs inside a transaction with the version bump — crash-safe.
161
196
  // Migrations must be idempotent (guard against already-applied state).
162
197
  // Never edit existing entries — add new ones at the end.
198
+ function migrateSceneJoinTableToProjectScope(db, tableName, tableSql, insertSql) {
199
+ const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
200
+ if (columns.some((column) => column.name === "project_id")) {
201
+ return {
202
+ migrated: false,
203
+ sourceCount: 0,
204
+ migratedCount: 0,
205
+ droppedCount: 0,
206
+ };
207
+ }
208
+
209
+ const sourceCount = db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get()?.count ?? 0;
210
+ db.exec(tableSql);
211
+ db.exec(insertSql);
212
+ const migratedCount = db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}_migrating`).get()?.count ?? 0;
213
+ const droppedCount = Math.max(0, sourceCount - migratedCount);
214
+ db.exec(`DROP TABLE ${tableName};`);
215
+ db.exec(`ALTER TABLE ${tableName}_migrating RENAME TO ${tableName};`);
216
+
217
+ return {
218
+ migrated: true,
219
+ sourceCount,
220
+ migratedCount,
221
+ droppedCount,
222
+ };
223
+ }
224
+
163
225
  const MIGRATIONS = [
164
226
  // 1: add chapter_title column to scenes
165
227
  (db) => {
@@ -282,6 +344,146 @@ const MIGRATIONS = [
282
344
  db.exec(`ALTER TABLE reference_links ADD COLUMN origin TEXT NOT NULL DEFAULT 'inferred';`);
283
345
  }
284
346
  },
347
+ // 7: scope scene join tables by project_id so duplicate scene IDs across projects are safe
348
+ (db) => {
349
+ const charactersMigration = migrateSceneJoinTableToProjectScope(
350
+ db,
351
+ "scene_characters",
352
+ `
353
+ CREATE TABLE scene_characters_migrating (
354
+ scene_id TEXT NOT NULL,
355
+ project_id TEXT NOT NULL,
356
+ character_id TEXT NOT NULL,
357
+ PRIMARY KEY (scene_id, project_id, character_id)
358
+ );
359
+ `,
360
+ `
361
+ INSERT OR IGNORE INTO scene_characters_migrating (scene_id, project_id, character_id)
362
+ SELECT sc.scene_id, s.project_id, sc.character_id
363
+ FROM scene_characters sc
364
+ JOIN scenes s ON s.scene_id = sc.scene_id
365
+ WHERE (
366
+ SELECT COUNT(*)
367
+ FROM scenes sx
368
+ WHERE sx.scene_id = sc.scene_id
369
+ ) = 1;
370
+ `
371
+ );
372
+
373
+ const placesMigration = migrateSceneJoinTableToProjectScope(
374
+ db,
375
+ "scene_places",
376
+ `
377
+ CREATE TABLE scene_places_migrating (
378
+ scene_id TEXT NOT NULL,
379
+ project_id TEXT NOT NULL,
380
+ place_id TEXT NOT NULL,
381
+ PRIMARY KEY (scene_id, project_id, place_id)
382
+ );
383
+ `,
384
+ `
385
+ INSERT OR IGNORE INTO scene_places_migrating (scene_id, project_id, place_id)
386
+ SELECT sp.scene_id, s.project_id, sp.place_id
387
+ FROM scene_places sp
388
+ JOIN scenes s ON s.scene_id = sp.scene_id
389
+ WHERE (
390
+ SELECT COUNT(*)
391
+ FROM scenes sx
392
+ WHERE sx.scene_id = sp.scene_id
393
+ ) = 1;
394
+ `
395
+ );
396
+
397
+ const tagsMigration = migrateSceneJoinTableToProjectScope(
398
+ db,
399
+ "scene_tags",
400
+ `
401
+ CREATE TABLE scene_tags_migrating (
402
+ scene_id TEXT NOT NULL,
403
+ project_id TEXT NOT NULL,
404
+ tag TEXT NOT NULL,
405
+ PRIMARY KEY (scene_id, project_id, tag)
406
+ );
407
+ `,
408
+ `
409
+ INSERT OR IGNORE INTO scene_tags_migrating (scene_id, project_id, tag)
410
+ SELECT st.scene_id, s.project_id, st.tag
411
+ FROM scene_tags st
412
+ JOIN scenes s ON s.scene_id = st.scene_id
413
+ WHERE (
414
+ SELECT COUNT(*)
415
+ FROM scenes sx
416
+ WHERE sx.scene_id = st.scene_id
417
+ ) = 1;
418
+ `
419
+ );
420
+
421
+ const threadsMigration = migrateSceneJoinTableToProjectScope(
422
+ db,
423
+ "scene_threads",
424
+ `
425
+ CREATE TABLE scene_threads_migrating (
426
+ scene_id TEXT NOT NULL,
427
+ project_id TEXT NOT NULL,
428
+ thread_id TEXT NOT NULL,
429
+ beat TEXT,
430
+ PRIMARY KEY (scene_id, project_id, thread_id)
431
+ );
432
+ `,
433
+ `
434
+ INSERT OR IGNORE INTO scene_threads_migrating (scene_id, project_id, thread_id, beat)
435
+ SELECT st.scene_id, t.project_id, st.thread_id, st.beat
436
+ FROM scene_threads st
437
+ JOIN threads t ON t.thread_id = st.thread_id
438
+ JOIN scenes s
439
+ ON s.scene_id = st.scene_id
440
+ AND s.project_id = t.project_id;
441
+
442
+ INSERT OR IGNORE INTO scene_threads_migrating (scene_id, project_id, thread_id, beat)
443
+ SELECT st.scene_id, s.project_id, st.thread_id, st.beat
444
+ FROM scene_threads st
445
+ JOIN scenes s ON s.scene_id = st.scene_id
446
+ LEFT JOIN threads t ON t.thread_id = st.thread_id
447
+ WHERE t.thread_id IS NULL
448
+ AND (
449
+ SELECT COUNT(*)
450
+ FROM scenes sx
451
+ WHERE sx.scene_id = st.scene_id
452
+ ) = 1;
453
+ `
454
+ );
455
+
456
+ const migrationSummaries = [
457
+ ["scene_characters", charactersMigration],
458
+ ["scene_places", placesMigration],
459
+ ["scene_tags", tagsMigration],
460
+ ["scene_threads", threadsMigration],
461
+ ];
462
+ const droppedByTable = {};
463
+ let totalDropped = 0;
464
+
465
+ for (const [tableName, summary] of migrationSummaries) {
466
+ if (!summary?.migrated || summary.droppedCount <= 0) continue;
467
+ droppedByTable[tableName] = {
468
+ source_rows: summary.sourceCount,
469
+ migrated_rows: summary.migratedCount,
470
+ skipped_rows: summary.droppedCount,
471
+ };
472
+ totalDropped += summary.droppedCount;
473
+ }
474
+
475
+ if (totalDropped > 0) {
476
+ dbStartupWarnings.push({
477
+ code: "LEGACY_JOIN_ROWS_SKIPPED",
478
+ message: "Legacy scene relationship rows were skipped during migration because scene_id was ambiguous across projects or duplicate links could not be preserved safely.",
479
+ details: {
480
+ skipped_rows_total: totalDropped,
481
+ skipped_rows_by_table: droppedByTable,
482
+ next_step: "Run sync() immediately after upgrade, then run enrich_scene(scene_id, project_id) for any stale scenes you touch.",
483
+ },
484
+ });
485
+ }
486
+ },
285
487
  ];
286
488
 
287
489
  // The version every database should reach after openDb. Not the current DB value —
@@ -312,6 +514,7 @@ function applyMigrations(db) {
312
514
  }
313
515
 
314
516
  export function openDb(dbPath) {
517
+ resetDbStartupWarnings();
315
518
  const db = new DatabaseSync(dbPath);
316
519
  db.exec(SCHEMA);
317
520
  applyMigrations(db);
package/src/index.js CHANGED
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { fileURLToPath } from "node:url";
9
- import { openDb, checkpointJobFinish, loadStalledJobs, pruneJobCheckpoints } from "./core/db.js";
9
+ import { openDb, getDbStartupWarnings, checkpointJobFinish, loadStalledJobs, pruneJobCheckpoints } from "./core/db.js";
10
10
  import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, isStructuralProjectId } from "./sync/sync.js";
11
11
  import { isGitAvailable, isGitRepository, initGitRepository, getSceneProseAtCommit } from "./core/git.js";
12
12
  import { createAsyncJobManager, readJsonIfExists } from "./runtime/async-jobs.js";
@@ -134,6 +134,7 @@ function errorResponse(code, message, details) {
134
134
  // Database setup
135
135
  // ---------------------------------------------------------------------------
136
136
  const db = openDb(DB_PATH);
137
+ const DB_STARTUP_WARNINGS = getDbStartupWarnings();
137
138
 
138
139
  // Recover jobs that were in-flight when the server last exited.
139
140
  const stalledJobs = loadStalledJobs(db);
@@ -166,6 +167,12 @@ const { pruneAsyncJobs, startAsyncJob, toPublicJob } = createAsyncJobManager({
166
167
 
167
168
  process.stderr.write(`[mcp-writing] Sync dir: ${SYNC_DIR_ABS}\n`);
168
169
  process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
170
+ if (DB_STARTUP_WARNINGS.length > 0) {
171
+ process.stderr.write(`[mcp-writing] WARNING: ${DB_STARTUP_WARNINGS.length} DB migration warning(s) detected.\n`);
172
+ for (const warning of DB_STARTUP_WARNINGS) {
173
+ process.stderr.write(`[mcp-writing] - ${warning.code}: ${warning.message}\n`);
174
+ }
175
+ }
169
176
 
170
177
  // Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
171
178
  const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
@@ -307,7 +314,7 @@ function createMcpServer() {
307
314
  // ---- describe_workflows --------------------------------------------------
308
315
  s.tool(
309
316
  "describe_workflows",
310
- "Return a map of available task workflows and the current project context. Call this at the start of a session or whenever you are unsure what to do next. Never write scripts to invoke tools — call them directly.",
317
+ "Return the default workflow map and current project context for this server. Call this first in most sessions and again whenever you are unsure what to do next. Never write scripts to invoke tools — call them directly.",
311
318
  {},
312
319
  async () => {
313
320
  const projectRow = db.prepare(
@@ -352,14 +359,19 @@ function createMcpServer() {
352
359
  },
353
360
  git_available: GIT_AVAILABLE,
354
361
  pending_proposals: pendingProposals.size,
362
+ db_migration_warnings: DB_STARTUP_WARNINGS,
355
363
  },
356
364
  workflows: WORKFLOW_CATALOGUE,
357
365
  notes: [
358
366
  "Never write JavaScript or shell scripts to invoke tools. Call them directly.",
367
+ "Use describe_workflows as the default starting point for most sessions and whenever you are uncertain which tool path fits the task.",
359
368
  "If a tool returns a next_step field (in a success or error response), follow it before trying anything else.",
360
369
  "Use find_scenes without filters to discover what project_ids are indexed.",
361
370
  "When calling bootstrap_prose_styleguide_config or check_prose_styleguide_drift, set max_scenes to context.scene_count to avoid the default limit.",
362
371
  "Styleguide tools resolve config in priority order: project_root > universe_root > sync_root. If any styleguide_exists field is true, a config exists and styleguide tools will work — do not run setup_prose_styleguide_config unless ALL styleguide_exists fields are false.",
372
+ ...(DB_STARTUP_WARNINGS.length > 0
373
+ ? ["Database migration warnings are present in context.db_migration_warnings. Run sync() now, then run enrich_scene(scene_id, project_id) for stale scenes you touch."]
374
+ : []),
363
375
  ],
364
376
  });
365
377
  }
@@ -416,6 +428,7 @@ function createMcpServer() {
416
428
  server_version: MCP_SERVER_VERSION,
417
429
  sync_dir: SYNC_DIR_ABS,
418
430
  db_path: DB_PATH_DISPLAY,
431
+ db_migration_warnings: DB_STARTUP_WARNINGS,
419
432
  sync_dir_writable: SYNC_DIR_WRITABLE,
420
433
  ownership_guard_mode: OWNERSHIP_GUARD_MODE,
421
434
  permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
@@ -160,25 +160,26 @@ export function buildReviewBundlePlan(dbHandle, {
160
160
 
161
161
  const requestedSceneIds = resolveRequestedSceneIds(dbHandle, project_id, scene_ids);
162
162
  const conditions = ["s.project_id = ?"];
163
- const params = [project_id];
164
163
  const joins = [];
164
+ const joinParams = [];
165
+ const conditionParams = [project_id];
165
166
 
166
167
  if (tag) {
167
- joins.push("JOIN scene_tags st ON st.scene_id = s.scene_id AND st.tag = ?");
168
- params.push(tag);
168
+ joins.push("JOIN scene_tags st ON st.scene_id = s.scene_id AND st.project_id = s.project_id AND st.tag = ?");
169
+ joinParams.push(tag);
169
170
  }
170
171
  if (Array.isArray(scene_ids) && scene_ids.length > 0) {
171
172
  const placeholders = scene_ids.map(() => "?").join(",");
172
173
  conditions.push(`s.scene_id IN (${placeholders})`);
173
- params.push(...scene_ids);
174
+ conditionParams.push(...scene_ids);
174
175
  }
175
176
  if (part !== undefined) {
176
177
  conditions.push("s.part = ?");
177
- params.push(part);
178
+ conditionParams.push(part);
178
179
  }
179
180
  if (chapter !== undefined) {
180
181
  conditions.push("s.chapter = ?");
181
- params.push(chapter);
182
+ conditionParams.push(chapter);
182
183
  }
183
184
 
184
185
  let query = `
@@ -202,7 +203,7 @@ export function buildReviewBundlePlan(dbHandle, {
202
203
  }
203
204
  query += ` WHERE ${conditions.join(" AND ")}`;
204
205
 
205
- const rows = dbHandle.prepare(query).all(...params).sort(sceneSort);
206
+ const rows = dbHandle.prepare(query).all(...joinParams, ...conditionParams).sort(sceneSort);
206
207
  if (rows.length === 0) {
207
208
  throw new ReviewBundlePlanError(
208
209
  "NO_RESULTS",
@@ -442,7 +442,7 @@ export function renderReviewBundlePdf(dbHandle, plan, { generatedAt, syncDir: sy
442
442
  doc.moveDown(0.2);
443
443
  }
444
444
 
445
- if (scene.logline) {
445
+ if (profile === "outline_discussion" && scene.logline) {
446
446
  doc.fontSize(10).font("Helvetica-Oblique");
447
447
  const textWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
448
448
  doc.text(`"${scene.logline}"`, { align: "left", width: textWidth });
@@ -0,0 +1,27 @@
1
+ # Manual Script Conventions
2
+
3
+ Manual scripts under this folder should use `mcp-result.mjs` for MCP tool parsing.
4
+
5
+ ## Use the shared parser
6
+
7
+ Import and call:
8
+
9
+ ```js
10
+ import { callToolParsed } from "./mcp-result.mjs";
11
+
12
+ const result = await callToolParsed(client, "get_scene_prose", { scene_id: "sc-001" });
13
+ ```
14
+
15
+ `callToolParsed(...)` returns:
16
+
17
+ - `raw`: original MCP response object
18
+ - `text`: `raw.content?.[0]?.text ?? ""`
19
+ - `data`: parsed JSON object when `text` is valid JSON, otherwise `null`
20
+ - `structured`: `raw.structuredContent` (if present)
21
+ - `isError`: `true` when `raw.isError` is set or parsed `data.ok === false`
22
+
23
+ ## Why this is required
24
+
25
+ Some tools now return advisory metadata in `structuredContent` (for example stale-metadata guidance on prose tools). Scripts that only read `content[0].text` can silently lose those warnings.
26
+
27
+ Using `callToolParsed` keeps text, JSON payloads, and structured metadata handled consistently across all manual scripts.
@@ -0,0 +1,27 @@
1
+ export function parseToolResult(result) {
2
+ const text = result?.content?.[0]?.text ?? "";
3
+ let data = null;
4
+ if (text) {
5
+ try {
6
+ data = JSON.parse(text);
7
+ } catch {
8
+ data = null;
9
+ }
10
+ }
11
+
12
+ return {
13
+ raw: result,
14
+ text,
15
+ data,
16
+ structured: result?.structuredContent,
17
+ isError: Boolean(
18
+ result?.isError
19
+ || (data && typeof data === "object" && data.ok === false)
20
+ ),
21
+ };
22
+ }
23
+
24
+ export async function callToolParsed(client, name, args = {}) {
25
+ const result = await client.callTool({ name, arguments: args });
26
+ return parseToolResult(result);
27
+ }
@@ -3,6 +3,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
3
3
  import path from "path";
4
4
  import process from "process";
5
5
  import fs from "node:fs";
6
+ import { callToolParsed } from "./mcp-result.mjs";
6
7
 
7
8
  async function main() {
8
9
  const [projectId, profile, outputDir, ...flags] = process.argv.slice(2);
@@ -64,29 +65,29 @@ async function main() {
64
65
  await client.connect(transport);
65
66
 
66
67
  if (!skipPreview) {
67
- const previewResult = await client.callTool({
68
- name: "preview_review_bundle",
69
- arguments: baseArguments,
70
- });
71
- const previewText = previewResult.content?.[0]?.text ?? "";
68
+ const previewResult = await callToolParsed(client, "preview_review_bundle", baseArguments);
69
+ const previewText = previewResult.text;
72
70
  console.log("\n=== preview_review_bundle ===");
73
71
  console.log(previewText);
72
+ if (previewResult.structured) {
73
+ console.log("structuredContent:", JSON.stringify(previewResult.structured, null, 2));
74
+ }
74
75
  }
75
76
 
76
- const result = await client.callTool({
77
- name: "create_review_bundle",
78
- arguments: {
79
- ...baseArguments,
80
- output_dir: outputDir,
81
- }
77
+ const result = await callToolParsed(client, "create_review_bundle", {
78
+ ...baseArguments,
79
+ output_dir: outputDir,
82
80
  });
83
81
 
84
- const resultText = result.content?.[0]?.text ?? "";
82
+ const resultText = result.text;
85
83
  console.log("\n=== create_review_bundle ===");
86
84
  console.log(resultText);
85
+ if (result.structured) {
86
+ console.log("structuredContent:", JSON.stringify(result.structured, null, 2));
87
+ }
87
88
 
88
89
  if (showFiles) {
89
- const parsed = JSON.parse(resultText);
90
+ const parsed = result.data ?? {};
90
91
  if (parsed.ok && parsed.output_paths) {
91
92
  printArtifactExcerpt("Bundle Markdown", parsed.output_paths.bundle_markdown);
92
93
  printArtifactExcerpt("Manifest JSON", parsed.output_paths.manifest_json);
@@ -2,13 +2,11 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
4
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { callToolParsed } from "./mcp-result.mjs";
5
6
 
6
7
  async function callCreateBundle(client, args) {
7
- const result = await client.callTool({
8
- name: "create_review_bundle",
9
- arguments: args,
10
- });
11
- console.log(JSON.stringify(result, null, 2));
8
+ const parsed = await callToolParsed(client, "create_review_bundle", args);
9
+ console.log(JSON.stringify(parsed.raw, null, 2));
12
10
  }
13
11
 
14
12
  async function main() {
@@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
3
  import path from "path";
4
4
  import process from "process";
5
+ import { callToolParsed } from "./mcp-result.mjs";
5
6
 
6
7
  function usage() {
7
8
  console.log("Usage: node src/scripts/manual/run_mcp_test.js <source_project_dir> [project_id] [sync_dir]");
@@ -30,17 +31,18 @@ async function runCase(env, args) {
30
31
  await client.connect(transport);
31
32
 
32
33
  // Start the async merge job
33
- const startResult = await client.callTool({
34
- name: "merge_scrivener_project_beta",
35
- arguments: args
36
- });
34
+ const startResult = await callToolParsed(client, "merge_scrivener_project_beta", args);
37
35
 
38
36
  if (startResult.isError) {
39
- console.log(`error: ${startResult.content[0].text}`);
37
+ console.log(`error: ${startResult.text}`);
40
38
  return;
41
39
  }
42
40
 
43
- const startData = JSON.parse(startResult.content[0].text);
41
+ const startData = startResult.data;
42
+ if (!startData) {
43
+ console.log("error: invalid start payload");
44
+ return;
45
+ }
44
46
  if (!startData.ok) {
45
47
  console.log(`error: ${startData.error?.code || 'unknown'}`);
46
48
  return;
@@ -57,22 +59,20 @@ async function runCase(env, args) {
57
59
  while (!settled && attempts < maxAttempts) {
58
60
  await new Promise(resolve => setTimeout(resolve, 500));
59
61
 
60
- const statusResult = await client.callTool({
61
- name: "get_async_job_status",
62
- arguments: { job_id: jobId, include_result: true }
62
+ const statusResult = await callToolParsed(client, "get_async_job_status", {
63
+ job_id: jobId,
64
+ include_result: true,
63
65
  });
64
66
 
65
67
  if (statusResult.isError) {
66
- console.log(`error.code/message: ${statusResult.content?.[0]?.text || "status query failed"}`);
68
+ console.log(`error.code/message: ${statusResult.text || "status query failed"}`);
67
69
  settled = true;
68
70
  break;
69
71
  }
70
72
 
71
- let statusData;
72
- try {
73
- statusData = JSON.parse(statusResult.content[0].text);
74
- } catch (parseError) {
75
- console.log(`error.code/message: invalid status payload: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
73
+ const statusData = statusResult.data;
74
+ if (!statusData) {
75
+ console.log("error.code/message: invalid status payload");
76
76
  settled = true;
77
77
  break;
78
78
  }