@hanna84/mcp-writing 1.11.6 → 1.11.7

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
+ #### [v1.11.7](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.11.6...v1.11.7)
9
+
10
+ - refactor: consolidate merge_scrivener_project_beta to async-only [`#66`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/66)
12
+
7
13
  #### [v1.11.6](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v1.11.5...v1.11.6)
9
15
 
16
+ > 24 April 2026
17
+
10
18
  - fix: improve MCP discoverability hints and tool guidance [`#65`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/65)
20
+ - Release 1.11.6 [`fa60a4a`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/fa60a4a91e2043f094a2f404f742e7d6b18e006e)
12
22
 
13
23
  #### [v1.11.5](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v1.11.4...v1.11.5)
package/index.js CHANGED
@@ -16,7 +16,6 @@ import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, getFileWriteDi
16
16
  import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
17
17
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
18
18
  import { importScrivenerSync, validateProjectId } from "./importer.js";
19
- import { mergeScrivenerProjectMetadata } from "./scrivener-direct.js";
20
19
  import { ASYNC_PROGRESS_PREFIX } from "./async-progress.js";
21
20
 
22
21
  const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
@@ -894,124 +893,6 @@ function createMcpServer() {
894
893
  }
895
894
  );
896
895
 
897
- // ---- merge_scrivener_project_beta --------------------------------------
898
- s.tool(
899
- "merge_scrivener_project_beta",
900
- "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars. This path is opt-in, requires sidecars to already exist (for example, from import_scrivener_sync), and may be sensitive to Scrivener internal format changes.",
901
- {
902
- source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
903
- project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb'). Defaults to a slug derived from WRITING_SYNC_DIR."),
904
- scenes_dir: z.string().optional().describe("Absolute path to the scenes directory containing .meta.yaml sidecars. Overrides the path derived from project_id. Use this for non-standard sync layouts."),
905
- dry_run: z.boolean().optional().describe("If true (default), reports planned merges without writing files."),
906
- auto_sync: z.boolean().optional().describe("If true (default), runs sync() after a non-dry-run merge."),
907
- organize_by_chapters: z.boolean().optional().describe("If true (default false), relocate scene files into chapter-based folder hierarchies (e.g., chapter-7-harbor/). Chapter metadata is always extracted to sidecars regardless of this flag."),
908
- },
909
- async ({ source_project_dir, project_id, scenes_dir, dry_run = true, auto_sync = true, organize_by_chapters = false }) => {
910
- if (project_id !== undefined) {
911
- const projectIdCheck = validateProjectId(project_id);
912
- if (!projectIdCheck.ok) {
913
- return errorResponse("INVALID_PROJECT_ID", projectIdCheck.reason, { project_id });
914
- }
915
- }
916
-
917
- if (!dry_run && !SYNC_DIR_WRITABLE) {
918
- return errorResponse(
919
- "SYNC_DIR_NOT_WRITABLE",
920
- "Cannot merge Scrivener metadata because WRITING_SYNC_DIR is not writable in this runtime.",
921
- { sync_dir: SYNC_DIR_ABS }
922
- );
923
- }
924
-
925
- const resolvedScenesDir = scenes_dir
926
- ?? (project_id ? path.join(resolveProjectRoot(project_id), "scenes") : undefined);
927
- const normalizedScenesDir = resolvedScenesDir ? path.resolve(resolvedScenesDir) : undefined;
928
-
929
- if (normalizedScenesDir) {
930
- if (!isPathInsideSyncDir(normalizedScenesDir)) {
931
- return errorResponse(
932
- "INVALID_SCENES_DIR",
933
- "scenes_dir must be inside WRITING_SYNC_DIR.",
934
- { scenes_dir: normalizedScenesDir, sync_dir: SYNC_DIR_ABS, sync_dir_real: SYNC_DIR_REAL }
935
- );
936
- }
937
- }
938
-
939
- let mergeResult;
940
- try {
941
- mergeResult = mergeScrivenerProjectMetadata({
942
- scrivPath: source_project_dir,
943
- mcpSyncDir: SYNC_DIR,
944
- projectId: project_id,
945
- scenesDir: normalizedScenesDir,
946
- dryRun: Boolean(dry_run),
947
- organizeByChapters: Boolean(organize_by_chapters),
948
- });
949
- } catch (error) {
950
- return errorResponse(
951
- "SCRIVENER_DIRECT_BETA_FAILED",
952
- error instanceof Error ? error.message : "Scrivener direct beta merge failed.",
953
- {
954
- source_project_dir,
955
- sync_dir: SYNC_DIR_ABS,
956
- project_id: project_id ?? null,
957
- fallback: "Use import_scrivener_sync with a Scrivener External Folder Sync export as the stable default path.",
958
- }
959
- );
960
- }
961
-
962
- let syncResult = null;
963
- if (!dry_run && auto_sync) {
964
- syncResult = syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
965
- }
966
-
967
- return jsonResponse({
968
- ok: true,
969
- beta: true,
970
- merge: {
971
- source_project_dir: mergeResult.scrivPath,
972
- sync_dir: mergeResult.mcpSyncDir,
973
- scenes_dir: mergeResult.scenesDir,
974
- project_id: mergeResult.projectId,
975
- dry_run: mergeResult.dryRun,
976
- sidecar_files: mergeResult.sidecarFiles,
977
- updated: mergeResult.updated,
978
- relocated: mergeResult.relocated,
979
- unchanged: mergeResult.unchanged,
980
- no_data: mergeResult.noData,
981
- field_add_counts: mergeResult.fieldAddCounts,
982
- preview_changes: mergeResult.previewChanges,
983
- warnings: mergeResult.warnings,
984
- warnings_truncated: mergeResult.warningsTruncated,
985
- warning_summary: mergeResult.warningSummary,
986
- stats: {
987
- sync_map_entries: mergeResult.stats.syncMapEntries,
988
- keyword_map_entries: mergeResult.stats.keywordMapEntries,
989
- binder_items: mergeResult.stats.binderItems,
990
- part_chapter_assignments: mergeResult.stats.partChapterAssignments,
991
- },
992
- },
993
- sync: syncResult
994
- ? {
995
- indexed: syncResult.indexed,
996
- stale_marked: syncResult.staleMarked,
997
- sidecars_migrated: syncResult.sidecarsMigrated,
998
- skipped: syncResult.skipped,
999
- warning_summary: syncResult.warningSummary,
1000
- }
1001
- : null,
1002
- next_step: dry_run
1003
- ? "Dry run complete. Re-run with dry_run=false to write metadata merges."
1004
- : auto_sync
1005
- ? "Beta merge and sync complete."
1006
- : "Beta merge complete. Run sync() to refresh index.",
1007
- warnings: [
1008
- "BETA_FEATURE: Direct Scrivener project parsing may be sensitive to Scrivener internal format changes.",
1009
- "If this fails, use import_scrivener_sync with an External Folder Sync export as the stable fallback.",
1010
- ],
1011
- });
1012
- }
1013
- );
1014
-
1015
896
  // ---- async import/merge jobs --------------------------------------------
1016
897
  s.tool(
1017
898
  "import_scrivener_sync_async",
@@ -1094,8 +975,8 @@ function createMcpServer() {
1094
975
  );
1095
976
 
1096
977
  s.tool(
1097
- "merge_scrivener_project_beta_async",
1098
- "[BETA] Start an asynchronous Scrivener metadata merge job from a `.scriv` project into existing scene sidecars. Use this only after the stable import path has created sidecars. Returns immediately with a job_id to poll via get_async_job_status.",
978
+ "merge_scrivener_project_beta",
979
+ "[BETA] Merge metadata directly from a Scrivener .scriv project into existing scene sidecars by starting a background job. This path is opt-in, requires sidecars to already exist (for example, from import_scrivener_sync), and may be sensitive to Scrivener internal format changes. Returns immediately with a job_id to poll via get_async_job_status.",
1099
980
  {
1100
981
  source_project_dir: z.string().describe("Path to a Scrivener .scriv bundle directory."),
1101
982
  project_id: z.string().optional().describe("Project ID containing existing sidecars (e.g. 'the-lamb' or 'universe-1/book-1-the-lamb')."),
@@ -1296,7 +1177,7 @@ function createMcpServer() {
1296
1177
 
1297
1178
  s.tool(
1298
1179
  "get_async_job_status",
1299
- "Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta_async, or enrich_scene_characters_batch. Use this to poll job progress after receiving a job_id. Common next step: if status is still running, call this tool again; if completed, inspect result and optionally run sync().",
1180
+ "Get status and result for an asynchronous job started by async tools such as import_scrivener_sync_async, merge_scrivener_project_beta, or enrich_scene_characters_batch. Use this to poll job progress after receiving a job_id. Common next step: if status is still running, call this tool again; if status is completed inspect result, and if status is failed or cancelled inspect job/result diagnostics.",
1300
1181
  {
1301
1182
  job_id: z.string().describe("Job ID returned by an async start tool."),
1302
1183
  include_result: z.boolean().optional().describe("If true (default), includes completed result payload when available."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.11.6",
3
+ "version": "1.11.7",
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",
@@ -25,6 +25,9 @@
25
25
  "start": "node --experimental-sqlite index.js",
26
26
  "new:entity": "node scripts/new-world-entity.js",
27
27
  "manual:realtest": "node scripts/manual-scrivener-realtest.mjs",
28
+ "manual:test": "node scripts/manual/test.mjs",
29
+ "manual:scenarios": "node scripts/manual/test-scenarios.mjs",
30
+ "manual:merge-beta-test": "node scripts/manual/run_mcp_test.js",
28
31
  "setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
29
32
  "release": "release-it",
30
33
  "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
@@ -164,15 +164,34 @@ try {
164
164
  await main();
165
165
  } catch (error) {
166
166
  const resultPath = process.argv[3];
167
+ const requestPath = process.argv[2];
167
168
  if (resultPath) {
168
- const errorCode = error && typeof error === "object" && typeof error.code === "string"
169
+ const baseErrorCode = error && typeof error === "object" && typeof error.code === "string"
169
170
  ? error.code
170
171
  : "ASYNC_JOB_FAILED";
171
- const errorDetails = error && typeof error === "object"
172
- ? {
173
- ...(error.pattern ? { pattern: error.pattern } : {}),
172
+ let requestKind = null;
173
+ if (requestPath && fs.existsSync(requestPath)) {
174
+ try {
175
+ const request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
176
+ requestKind = request?.kind ?? null;
177
+ } catch {
178
+ requestKind = null;
174
179
  }
175
- : {};
180
+ }
181
+
182
+ const errorCode = requestKind === "merge_scrivener_project_beta" && baseErrorCode === "ASYNC_JOB_FAILED"
183
+ ? "SCRIVENER_DIRECT_BETA_FAILED"
184
+ : baseErrorCode;
185
+
186
+ const errorDetails = {
187
+ ...(error && typeof error === "object" && error.pattern ? { pattern: error.pattern } : {}),
188
+ ...(error && typeof error === "object" && error.details && typeof error.details === "object" ? error.details : {}),
189
+ ...(requestKind === "merge_scrivener_project_beta"
190
+ ? {
191
+ fallback: "Use import_scrivener_sync with an External Folder Sync export as the stable default path.",
192
+ }
193
+ : {}),
194
+ };
176
195
  writeResult(resultPath, {
177
196
  ok: false,
178
197
  error: {
@@ -0,0 +1,135 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import path from "path";
4
+ import process from "process";
5
+
6
+ function usage() {
7
+ console.log("Usage: node scripts/manual/run_mcp_test.js <source_project_dir> [project_id] [sync_dir]");
8
+ console.log("Example: node scripts/manual/run_mcp_test.js ~/Novel.scriv demo-project ~/sync-root");
9
+ }
10
+
11
+ async function runCase(env, args) {
12
+ const transport = new StdioClientTransport({
13
+ command: process.execPath,
14
+ args: ["--experimental-sqlite", path.join(process.cwd(), "index.js")],
15
+ env: {
16
+ ...process.env,
17
+ MCP_TRANSPORT: "stdio",
18
+ ...env,
19
+ }
20
+ });
21
+
22
+ const client = new Client({
23
+ name: "test-client",
24
+ version: "1.0.0"
25
+ }, {
26
+ capabilities: {}
27
+ });
28
+
29
+ try {
30
+ await client.connect(transport);
31
+
32
+ // Start the async merge job
33
+ const startResult = await client.callTool({
34
+ name: "merge_scrivener_project_beta",
35
+ arguments: args
36
+ });
37
+
38
+ if (startResult.isError) {
39
+ console.log(`error: ${startResult.content[0].text}`);
40
+ return;
41
+ }
42
+
43
+ const startData = JSON.parse(startResult.content[0].text);
44
+ if (!startData.ok) {
45
+ console.log(`error: ${startData.error?.code || 'unknown'}`);
46
+ return;
47
+ }
48
+
49
+ const jobId = startData.job.job_id;
50
+ console.log(`Job started: ${jobId}`);
51
+
52
+ // Poll for completion (with timeout)
53
+ let settled = false;
54
+ let attempts = 0;
55
+ const maxAttempts = 120; // 120 * 500ms = 60 seconds
56
+
57
+ while (!settled && attempts < maxAttempts) {
58
+ await new Promise(resolve => setTimeout(resolve, 500));
59
+
60
+ const statusResult = await client.callTool({
61
+ name: "get_async_job_status",
62
+ arguments: { job_id: jobId, include_result: true }
63
+ });
64
+
65
+ if (statusResult.isError) {
66
+ console.log(`error.code/message: ${statusResult.content?.[0]?.text || "status query failed"}`);
67
+ settled = true;
68
+ break;
69
+ }
70
+
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)}`);
76
+ settled = true;
77
+ break;
78
+ }
79
+
80
+ if (!statusData.ok) {
81
+ console.log(`error.code/message: ${statusData.error?.code || "unknown"}`);
82
+ settled = true;
83
+ }
84
+
85
+ const terminalStatuses = new Set(["completed", "failed", "cancelled"]);
86
+ if (!settled && terminalStatuses.has(statusData.job?.status)) {
87
+ settled = true;
88
+ }
89
+
90
+ if (statusData.job?.status === "completed") {
91
+ const result = statusData.job.result;
92
+
93
+ if (!result.ok) {
94
+ console.log(`error.code/message: ${result.error?.code}`);
95
+ } else {
96
+ const merge = result.merge || {};
97
+ console.log("ok");
98
+ if (merge.sidecar_files) console.log(`sidecar_files: ${merge.sidecar_files}`);
99
+ if (merge.updated) console.log(`updated: ${merge.updated}`);
100
+ }
101
+ }
102
+
103
+ if (statusData.job?.status === "failed" || statusData.job?.status === "cancelled") {
104
+ const details = statusData.job?.result?.error?.code || statusData.job?.status;
105
+ console.log(`error.code/message: ${details}`);
106
+ }
107
+
108
+ attempts++;
109
+ }
110
+
111
+ if (!settled) {
112
+ console.log("error: job timeout");
113
+ }
114
+ } catch (e) {
115
+ console.log(`error.code/message: ${e.message}`);
116
+ }
117
+ }
118
+
119
+ async function main() {
120
+ const [sourceProjectDir, projectId = "demo-project", syncDir = process.env.WRITING_SYNC_DIR || "./sync"] = process.argv.slice(2);
121
+ if (!sourceProjectDir) {
122
+ usage();
123
+ process.exit(1);
124
+ }
125
+
126
+ await runCase({ WRITING_SYNC_DIR: syncDir }, {
127
+ source_project_dir: sourceProjectDir,
128
+ project_id: projectId,
129
+ dry_run: true,
130
+ });
131
+
132
+ process.exit(0);
133
+ }
134
+
135
+ main();
@@ -0,0 +1,304 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+
4
+ const BASE_URL = "http://localhost:3000";
5
+
6
+ async function connect() {
7
+ // Wait for server to be ready
8
+ for (let i = 0; i < 30; i++) {
9
+ try {
10
+ const res = await fetch(`${BASE_URL}/sse`);
11
+ if (!res.ok && res.status !== 404) continue; // Still waiting
12
+ break;
13
+ } catch {
14
+ await new Promise(r => setTimeout(r, 200));
15
+ }
16
+ }
17
+
18
+ const client = new Client({ name: "test-scenarios", version: "1.0.0" });
19
+ const transport = new SSEClientTransport(new URL(`${BASE_URL}/sse`));
20
+ await client.connect(transport);
21
+ return client;
22
+ }
23
+
24
+ async function callTool(client, name, args = {}) {
25
+ const result = await client.callTool({ name, arguments: args });
26
+ return result.content?.[0]?.text ?? "";
27
+ }
28
+
29
+ const results = [];
30
+
31
+ function log(scenario, status, details) {
32
+ results.push({ scenario, status, details });
33
+ console.log(`\n${scenario}: ${status}`);
34
+ if (details) console.log(` ${details}`);
35
+ }
36
+
37
+ async function runScenarios() {
38
+ const client = await connect();
39
+
40
+ try {
41
+ // ========================================================================
42
+ // 1. Baseline list without pagination args (find_scenes)
43
+ // ========================================================================
44
+ console.log("\n=== Scenario 1: Baseline find_scenes (no pagination) ===");
45
+ try {
46
+ const text = await callTool(client, "find_scenes", { project_id: "the-lamb" });
47
+ const parsed = JSON.parse(text);
48
+ const isArray = Array.isArray(parsed);
49
+ const isEnvelope = !isArray && parsed.results && parsed.total_count !== undefined;
50
+
51
+ if (isArray) {
52
+ log("1a", "PASS", `Got array of ${parsed.length} scenes (backward compat)`);
53
+ } else if (isEnvelope) {
54
+ log("1b", "PASS", `Got envelope with results: ${parsed.results.length}, total_count: ${parsed.total_count}`);
55
+ } else {
56
+ log("1c", "FAIL", `Unknown shape: ${JSON.stringify(parsed).slice(0, 100)}`);
57
+ }
58
+ } catch (err) {
59
+ log("1", "FAIL", err.message);
60
+ }
61
+
62
+ // ========================================================================
63
+ // 2. Explicit pagination on find_scenes
64
+ // ========================================================================
65
+ console.log("\n=== Scenario 2: find_scenes with explicit pagination ===");
66
+ try {
67
+ const page1 = JSON.parse(await callTool(client, "find_scenes", { project_id: "the-lamb", page_size: 5, page: 1 }));
68
+ const page2 = JSON.parse(await callTool(client, "find_scenes", { project_id: "the-lamb", page_size: 5, page: 2 }));
69
+
70
+ const p1Ids = page1.results.map(s => s.scene_id);
71
+ const p2Ids = page2.results.map(s => s.scene_id);
72
+ const overlap = p1Ids.filter(id => p2Ids.includes(id));
73
+
74
+ if (page1.total_count === page2.total_count && overlap.length === 0) {
75
+ log("2", "PASS", `Page 1 (${p1Ids.length}), Page 2 (${p2Ids.length}), total_count: ${page1.total_count}, no overlap`);
76
+ } else {
77
+ log("2", "FAIL", `Overlap: ${overlap.length}, totals: ${page1.total_count} vs ${page2.total_count}`);
78
+ }
79
+ } catch (err) {
80
+ log("2", "FAIL", err.message);
81
+ }
82
+
83
+ // ========================================================================
84
+ // 3. Explicit pagination on get_arc
85
+ // ========================================================================
86
+ console.log("\n=== Scenario 3: get_arc with explicit pagination ===");
87
+ try {
88
+ const arc1 = JSON.parse(await callTool(client, "get_arc", { character_id: "char-mira-nystrom", page_size: 3, page: 1 }));
89
+ const arc2 = JSON.parse(await callTool(client, "get_arc", { character_id: "char-mira-nystrom", page_size: 3, page: 2 }));
90
+
91
+ const sumLen = arc1.results.length + arc2.results.length;
92
+ const check = arc1.total_count >= sumLen && arc1.results[0].part === 1;
93
+
94
+ if (check) {
95
+ log("3", "PASS", `Page 1 (${arc1.results.length}), Page 2 (${arc2.results.length}), total: ${arc1.total_count}, ordered by part`);
96
+ } else {
97
+ log("3", "FAIL", `total_count: ${arc1.total_count}, page sum: ${sumLen}`);
98
+ }
99
+ } catch (err) {
100
+ log("3", "FAIL", err.message);
101
+ }
102
+
103
+ // ========================================================================
104
+ // 4. Explicit pagination on search_metadata
105
+ // ========================================================================
106
+ console.log("\n=== Scenario 4: search_metadata with pagination ===");
107
+ try {
108
+ const s1 = JSON.parse(await callTool(client, "search_metadata", { query: "scene", page_size: 2, page: 1 }));
109
+ const s2 = JSON.parse(await callTool(client, "search_metadata", { query: "scene", page_size: 2, page: 2 }));
110
+
111
+ if (s1.total_count > s1.results.length && s1.page === 1 && s2.page === 2) {
112
+ log("4", "PASS", `Page 1 (${s1.results.length}), Page 2 (${s2.results.length}), total: ${s1.total_count}`);
113
+ } else {
114
+ log("4", "FAIL", `Page meta inconsistency: ${JSON.stringify({ p1: s1.page, p2: s2.page, total: s1.total_count })}`);
115
+ }
116
+ } catch (err) {
117
+ log("4", "FAIL", err.message);
118
+ }
119
+
120
+ // ========================================================================
121
+ // 5. list_threads empty project case
122
+ // ========================================================================
123
+ console.log("\n=== Scenario 5: list_threads on empty project ===");
124
+ try {
125
+ const text = await callTool(client, "list_threads", { project_id: "the-lamb" });
126
+ const parsed = JSON.parse(text);
127
+
128
+ if (parsed.project_id === "the-lamb" && parsed.total_count === 0 && Array.isArray(parsed.results)) {
129
+ log("5", "PASS", `Structured JSON: project_id, results [], total_count: 0`);
130
+ } else {
131
+ log("5", "FAIL", `Missing fields or wrong structure: ${JSON.stringify(parsed).slice(0, 100)}`);
132
+ }
133
+ } catch (err) {
134
+ log("5", "FAIL", err.message);
135
+ }
136
+
137
+ // ========================================================================
138
+ // 6. list_threads non-empty project (if any exist)
139
+ // ========================================================================
140
+ console.log("\n=== Scenario 6: list_threads non-empty (test-novel) ===");
141
+ try {
142
+ const text = await callTool(client, "list_threads", { project_id: "test-novel" });
143
+ const parsed = JSON.parse(text);
144
+
145
+ const threadCount = parsed.results.length;
146
+ const matchesTotal = threadCount === parsed.total_count;
147
+
148
+ if (matchesTotal && parsed.project_id === "test-novel") {
149
+ log("6", "PASS", `${threadCount} threads, total_count: ${parsed.total_count}`);
150
+ } else {
151
+ log("6", "FAIL", `results vs total mismatch: ${threadCount} vs ${parsed.total_count}`);
152
+ }
153
+ } catch (err) {
154
+ log("6", "FAIL", err.message);
155
+ }
156
+
157
+ // ========================================================================
158
+ // 7. get_thread_arc happy path (we may not have threads, check structure)
159
+ // ========================================================================
160
+ console.log("\n=== Scenario 7: get_thread_arc structure check ===");
161
+ try {
162
+ // Try to get any thread first
163
+ const threadList = JSON.parse(await callTool(client, "list_threads", { project_id: "test-novel" }));
164
+ if (threadList.results.length === 0) {
165
+ log("7", "SKIP", "No threads in test-novel, skipping");
166
+ } else {
167
+ const threadId = threadList.results[0].thread_id;
168
+ const arcText = await callTool(client, "get_thread_arc", { thread_id: threadId });
169
+ const parsed = JSON.parse(arcText);
170
+
171
+ if (parsed.thread && parsed.results && parsed.total_count !== undefined) {
172
+ log("7", "PASS", `Envelope with thread name: "${parsed.thread.name}", results: ${parsed.results.length}`);
173
+ } else {
174
+ log("7", "FAIL", `Missing envelope fields: ${JSON.stringify(Object.keys(parsed))}`);
175
+ }
176
+ }
177
+ } catch (err) {
178
+ log("7", "FAIL", err.message);
179
+ }
180
+
181
+ // ========================================================================
182
+ // 8. get_thread_arc unknown thread
183
+ // ========================================================================
184
+ console.log("\n=== Scenario 8: get_thread_arc with fake thread_id ===");
185
+ try {
186
+ const text = await callTool(client, "get_thread_arc", { thread_id: "fake-thread-999" });
187
+ // Currently expects text error message
188
+ if (text.toLowerCase().includes("not found")) {
189
+ log("8", "PASS", `Error handling works: "${text.slice(0, 50)}..."`);
190
+ } else {
191
+ log("8", "WARN", `Unexpected response: "${text.slice(0, 80)}..."`);
192
+ }
193
+ } catch (err) {
194
+ log("8", "FAIL", err.message);
195
+ }
196
+
197
+ // ========================================================================
198
+ // 9. Warning behavior (scenes with stale metadata)
199
+ // ========================================================================
200
+ console.log("\n=== Scenario 9: Warning on stale metadata ===");
201
+ try {
202
+ const text = await callTool(client, "get_arc", { character_id: "char-mira-nystrom" });
203
+ const parsed = JSON.parse(text);
204
+
205
+ const hasStaleScenes = parsed.results.some(s => s.metadata_stale);
206
+ const hasWarning = parsed.warning !== undefined;
207
+
208
+ if (hasStaleScenes && hasWarning) {
209
+ log("9", "PASS", `Stale scenes detected: ${hasStaleScenes}, warning included: "${parsed.warning.slice(0, 60)}..."`);
210
+ } else if (!hasStaleScenes) {
211
+ log("9", "SKIP", "No stale scenes in test data");
212
+ } else {
213
+ log("9", "FAIL", `Stale scenes found but no warning`);
214
+ }
215
+ } catch (err) {
216
+ log("9", "FAIL", err.message);
217
+ }
218
+
219
+ // ========================================================================
220
+ // 10. Page bounds behavior (page far beyond total_pages)
221
+ // ========================================================================
222
+ console.log("\n=== Scenario 10: Page bounds (page beyond range) ===");
223
+ try {
224
+ const resp = JSON.parse(await callTool(client, "find_scenes", { project_id: "the-lamb", page_size: 5, page: 9999 }));
225
+
226
+ if (resp.page <= resp.total_pages && !isNaN(resp.page)) {
227
+ log("10", "PASS", `Page normalized to ${resp.page} of ${resp.total_pages}`);
228
+ } else {
229
+ log("10", "FAIL", `Page not normalized: ${resp.page} > ${resp.total_pages}`);
230
+ }
231
+ } catch (err) {
232
+ log("10", "FAIL", err.message);
233
+ }
234
+
235
+ // ========================================================================
236
+ // 11. Cross-tool shape consistency
237
+ // ========================================================================
238
+ console.log("\n=== Scenario 11: Cross-tool shape consistency ===");
239
+ try {
240
+ const fs = JSON.parse(await callTool(client, "find_scenes", { project_id: "the-lamb", page_size: 1 }));
241
+ const ga = JSON.parse(await callTool(client, "get_arc", { character_id: "char-mira-nystrom", page_size: 1 }));
242
+ const sm = JSON.parse(await callTool(client, "search_metadata", { query: "scene", page_size: 1 }));
243
+ const lt = JSON.parse(await callTool(client, "list_threads", { project_id: "the-lamb" }));
244
+
245
+ const commonFields = ["results", "total_count"];
246
+ const fsHas = commonFields.every(f => f in fs);
247
+ const gaHas = commonFields.every(f => f in ga);
248
+ const smHas = commonFields.every(f => f in sm);
249
+ const ltHas = commonFields.every(f => f in lt);
250
+
251
+ if (fsHas && gaHas && smHas && ltHas) {
252
+ log("11", "PASS", `All tools have results + total_count envelope`);
253
+ } else {
254
+ log("11", "FAIL", `Missing fields: fs=${fsHas}, ga=${gaHas}, sm=${smHas}, lt=${ltHas}`);
255
+ }
256
+ } catch (err) {
257
+ log("11", "FAIL", err.message);
258
+ }
259
+
260
+ // ========================================================================
261
+ // 12. Client-mapping smoke test
262
+ // ========================================================================
263
+ console.log("\n=== Scenario 12: Client mapping smoke test ===");
264
+ try {
265
+ const smoke = {
266
+ fs: JSON.parse(await callTool(client, "find_scenes", { project_id: "the-lamb" })),
267
+ ga: JSON.parse(await callTool(client, "get_arc", { character_id: "char-mira-nystrom" })),
268
+ sm: JSON.parse(await callTool(client, "search_metadata", { query: "scene" })),
269
+ lt: JSON.parse(await callTool(client, "list_threads", { project_id: "the-lamb" })),
270
+ };
271
+
272
+ const allValid = Object.values(smoke).every(obj => obj !== null && typeof obj === "object");
273
+
274
+ if (allValid) {
275
+ log("12", "PASS", `All 4 tools parsed as valid JSON objects`);
276
+ } else {
277
+ log("12", "FAIL", `Some responses did not parse as JSON`);
278
+ }
279
+ } catch (err) {
280
+ log("12", "FAIL", err.message);
281
+ }
282
+
283
+ } finally {
284
+ await client.close();
285
+ }
286
+
287
+ // ============================================================================
288
+ // Print summary table
289
+ // ============================================================================
290
+ console.log("\n\n" + "=".repeat(80));
291
+ console.log("SUMMARY TABLE");
292
+ console.log("=".repeat(80));
293
+ console.log("\n");
294
+
295
+ const passCount = results.filter(r => r.status === "PASS").length;
296
+ const failCount = results.filter(r => r.status === "FAIL").length;
297
+ const skipCount = results.filter(r => r.status === "SKIP").length;
298
+ const warnCount = results.filter(r => r.status === "WARN").length;
299
+
300
+ console.table(results);
301
+ console.log(`\nPASS: ${passCount}, FAIL: ${failCount}, SKIP: ${skipCount}, WARN: ${warnCount}`);
302
+ }
303
+
304
+ runScenarios().catch(console.error);
@@ -0,0 +1,175 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+
4
+ const BASE_URL = "http://localhost:3000";
5
+
6
+ const client = new Client({ name: "test-client", version: "1.0.0" });
7
+ const transport = new SSEClientTransport(new URL(`${BASE_URL}/sse`));
8
+ await client.connect(transport);
9
+
10
+ let passed = 0;
11
+ let failed = 0;
12
+
13
+ async function test(label, toolName, args, check) {
14
+ try {
15
+ const result = await client.callTool({ name: toolName, arguments: args });
16
+ const text = result.content?.[0]?.text ?? "";
17
+ const ok = check(text);
18
+ if (ok) {
19
+ console.log(` ✓ ${label}`);
20
+ passed++;
21
+ } else {
22
+ console.log(` ✗ ${label}`);
23
+ console.log(` Output: ${text.slice(0, 200)}`);
24
+ failed++;
25
+ }
26
+ } catch (err) {
27
+ console.log(` ✗ ${label} — ERROR: ${err.message}`);
28
+ failed++;
29
+ }
30
+ }
31
+
32
+ console.log("\n── sync ──────────────────────────────────────────────────────");
33
+ await test(
34
+ "sync returns indexed count",
35
+ "sync", {},
36
+ t => t.includes("3 scenes indexed")
37
+ );
38
+
39
+ console.log("\n── find_scenes ───────────────────────────────────────────────");
40
+ await test(
41
+ "find all scenes returns 3",
42
+ "find_scenes", {},
43
+ t => (t.match(/"scene_id"/g) ?? []).length === 3
44
+ );
45
+
46
+ await test(
47
+ "filter by character elena returns 3 scenes",
48
+ "find_scenes", { character: "elena" },
49
+ t => (t.match(/"scene_id"/g) ?? []).length === 3
50
+ );
51
+
52
+ await test(
53
+ "filter by character marcus returns 2 scenes (sc-001, sc-002)",
54
+ "find_scenes", { character: "marcus" },
55
+ t => (t.match(/"scene_id"/g) ?? []).length === 2
56
+ );
57
+
58
+ await test(
59
+ "filter by beat 'Catalyst' returns sc-003",
60
+ "find_scenes", { beat: "Catalyst" },
61
+ t => t.includes("sc-003") && !(t.includes("sc-001")) && !(t.includes("sc-002"))
62
+ );
63
+
64
+ await test(
65
+ "filter by chapter 1 returns 2 scenes",
66
+ "find_scenes", { chapter: 1 },
67
+ t => (t.match(/"scene_id"/g) ?? []).length === 2
68
+ );
69
+
70
+ await test(
71
+ "filter by tag 'harbor' returns scenes 001 and 002",
72
+ "find_scenes", { tag: "harbor" },
73
+ t => t.includes("sc-001") && t.includes("sc-002") && !t.includes("sc-003")
74
+ );
75
+
76
+ console.log("\n── get_scene_prose ───────────────────────────────────────────");
77
+ await test(
78
+ "returns prose for sc-001",
79
+ "get_scene_prose", { scene_id: "sc-001" },
80
+ t => t.includes("gangway") && t.includes("Marcus")
81
+ );
82
+
83
+ await test(
84
+ "returns prose for sc-003",
85
+ "get_scene_prose", { scene_id: "sc-003" },
86
+ t => t.includes("father") && t.includes("envelope")
87
+ );
88
+
89
+ await test(
90
+ "returns error for unknown scene",
91
+ "get_scene_prose", { scene_id: "sc-999" },
92
+ t => t.includes("not found")
93
+ );
94
+
95
+ console.log("\n── get_chapter_prose ─────────────────────────────────────────");
96
+ await test(
97
+ "returns both scenes from part 1 chapter 1",
98
+ "get_chapter_prose", { project_id: "test-novel", part: 1, chapter: 1 },
99
+ t => t.includes("gangway") && t.includes("bait shed")
100
+ );
101
+
102
+ console.log("\n── get_arc ───────────────────────────────────────────────────");
103
+ await test(
104
+ "elena arc returns 3 scenes in order",
105
+ "get_arc", { character_id: "elena" },
106
+ t => {
107
+ const ids = [...t.matchAll(/"scene_id": "([^"]+)"/g)].map(m => m[1]);
108
+ return ids.length === 3 && ids[0] === "sc-001" && ids[2] === "sc-003";
109
+ }
110
+ );
111
+
112
+ await test(
113
+ "marcus arc returns only 2 scenes",
114
+ "get_arc", { character_id: "marcus" },
115
+ t => (t.match(/"scene_id"/g) ?? []).length === 2
116
+ );
117
+
118
+ console.log("\n── list_characters ───────────────────────────────────────────");
119
+ await test(
120
+ "lists elena and marcus",
121
+ "list_characters", {},
122
+ t => t.includes("elena") && t.includes("marcus")
123
+ );
124
+
125
+ console.log("\n── get_character_sheet ───────────────────────────────────────");
126
+ await test(
127
+ "elena sheet includes traits and notes",
128
+ "get_character_sheet", { character_id: "elena" },
129
+ t => t.includes("driven") && t.includes("self-sabotaging") && t.includes("walls")
130
+ );
131
+
132
+ await test(
133
+ "marcus sheet includes arc_summary",
134
+ "get_character_sheet", { character_id: "marcus" },
135
+ t => t.includes("loyalty") && t.includes("patient")
136
+ );
137
+
138
+ console.log("\n── list_places ───────────────────────────────────────────────");
139
+ await test(
140
+ "lists harbor-district",
141
+ "list_places", {},
142
+ t => t.includes("harbor-district")
143
+ );
144
+
145
+ console.log("\n── search_metadata ───────────────────────────────────────────");
146
+ await test(
147
+ "search 'harbor' returns relevant scenes",
148
+ "search_metadata", { query: "harbor" },
149
+ t => t.includes("sc-001") || t.includes("sc-002")
150
+ );
151
+
152
+ await test(
153
+ "search 'envelope' returns sc-003 (word in logline)",
154
+ "search_metadata", { query: "envelope" },
155
+ t => t.includes("sc-003")
156
+ );
157
+
158
+ await test(
159
+ "search with no match returns helpful message",
160
+ "search_metadata", { query: "dragons" },
161
+ t => t.toLowerCase().includes("no scenes")
162
+ );
163
+
164
+ console.log("\n── list_threads ──────────────────────────────────────────────");
165
+ await test(
166
+ "returns empty / no threads message (none created yet)",
167
+ "list_threads", { project_id: "projects/test-novel" },
168
+ t => t.toLowerCase().includes("no threads")
169
+ );
170
+
171
+ console.log("\n──────────────────────────────────────────────────────────────");
172
+ console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
173
+
174
+ await client.close();
175
+ process.exit(failed > 0 ? 1 : 0);