@hanna84/mcp-writing 1.11.5 → 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 +20 -0
- package/index.js +13 -132
- package/package.json +4 -1
- package/scripts/async-job-runner.mjs +24 -5
- package/scripts/manual/run_mcp_test.js +135 -0
- package/scripts/manual/test-scenarios.mjs +304 -0
- package/scripts/manual/test.mjs +175 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,31 @@ 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
|
+
|
|
13
|
+
#### [v1.11.6](https://github.com/hannasdev/mcp-writing.git
|
|
14
|
+
/compare/v1.11.5...v1.11.6)
|
|
15
|
+
|
|
16
|
+
> 24 April 2026
|
|
17
|
+
|
|
18
|
+
- fix: improve MCP discoverability hints and tool guidance [`#65`](https://github.com/hannasdev/mcp-writing.git
|
|
19
|
+
/pull/65)
|
|
20
|
+
- Release 1.11.6 [`fa60a4a`](https://github.com/hannasdev/mcp-writing.git
|
|
21
|
+
/commit/fa60a4a91e2043f094a2f404f742e7d6b18e006e)
|
|
22
|
+
|
|
7
23
|
#### [v1.11.5](https://github.com/hannasdev/mcp-writing.git
|
|
8
24
|
/compare/v1.11.4...v1.11.5)
|
|
9
25
|
|
|
26
|
+
> 24 April 2026
|
|
27
|
+
|
|
10
28
|
- Add Copilot instructions and contribution workflow [`#64`](https://github.com/hannasdev/mcp-writing.git
|
|
11
29
|
/pull/64)
|
|
30
|
+
- Release 1.11.5 [`9b72b8b`](https://github.com/hannasdev/mcp-writing.git
|
|
31
|
+
/commit/9b72b8b827ccc7b9ab7532fbb355ab5894b56a59)
|
|
12
32
|
|
|
13
33
|
#### [v1.11.4](https://github.com/hannasdev/mcp-writing.git
|
|
14
34
|
/compare/v1.11.3...v1.11.4)
|
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
|
-
"
|
|
1098
|
-
"[BETA]
|
|
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,
|
|
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."),
|
|
@@ -1305,7 +1186,7 @@ function createMcpServer() {
|
|
|
1305
1186
|
pruneAsyncJobs();
|
|
1306
1187
|
const job = asyncJobs.get(job_id);
|
|
1307
1188
|
if (!job) {
|
|
1308
|
-
return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired.`);
|
|
1189
|
+
return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired. Hint: call list_async_jobs to see currently tracked job IDs.`);
|
|
1309
1190
|
}
|
|
1310
1191
|
return jsonResponse({ ok: true, async: true, job: toPublicJob(job, include_result) });
|
|
1311
1192
|
}
|
|
@@ -1313,7 +1194,7 @@ function createMcpServer() {
|
|
|
1313
1194
|
|
|
1314
1195
|
s.tool(
|
|
1315
1196
|
"list_async_jobs",
|
|
1316
|
-
"List asynchronous jobs currently known to this server.",
|
|
1197
|
+
"List asynchronous jobs currently known to this server. Use this when you lost a job_id or need a dashboard view of running/completed jobs. Returns an object envelope containing a jobs array of job objects sorted by newest first.",
|
|
1317
1198
|
{
|
|
1318
1199
|
include_results: z.boolean().optional().describe("If true, includes completed result payloads."),
|
|
1319
1200
|
},
|
|
@@ -1328,7 +1209,7 @@ function createMcpServer() {
|
|
|
1328
1209
|
|
|
1329
1210
|
s.tool(
|
|
1330
1211
|
"cancel_async_job",
|
|
1331
|
-
"Cancel a running asynchronous job.",
|
|
1212
|
+
"Cancel a running asynchronous job. Use this when an import/merge/batch run was started with overly broad scope or is no longer needed. Returns the updated job state; cancellation is cooperative and may transition through 'cancelling' before 'cancelled'.",
|
|
1332
1213
|
{
|
|
1333
1214
|
job_id: z.string().describe("Job ID returned by an async start tool."),
|
|
1334
1215
|
},
|
|
@@ -1336,7 +1217,7 @@ function createMcpServer() {
|
|
|
1336
1217
|
pruneAsyncJobs();
|
|
1337
1218
|
const job = asyncJobs.get(job_id);
|
|
1338
1219
|
if (!job) {
|
|
1339
|
-
return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired.`);
|
|
1220
|
+
return errorResponse("NOT_FOUND", `Async job '${job_id}' was not found. It may have expired. Hint: call list_async_jobs to find active IDs.`);
|
|
1340
1221
|
}
|
|
1341
1222
|
|
|
1342
1223
|
if (job.status !== "running") {
|
|
@@ -1461,7 +1342,7 @@ function createMcpServer() {
|
|
|
1461
1342
|
|
|
1462
1343
|
const rows = db.prepare(query).all(...params);
|
|
1463
1344
|
if (rows.length === 0) {
|
|
1464
|
-
return errorResponse("NO_RESULTS", "No scenes match the given filters.");
|
|
1345
|
+
return errorResponse("NO_RESULTS", "No scenes match the given filters. Hint: broaden filters or call search_metadata with a keyword first.");
|
|
1465
1346
|
}
|
|
1466
1347
|
|
|
1467
1348
|
const staleCount = rows.filter(r => r.metadata_stale).length;
|
|
@@ -1909,7 +1790,7 @@ function createMcpServer() {
|
|
|
1909
1790
|
// ---- list_threads --------------------------------------------------------
|
|
1910
1791
|
s.tool(
|
|
1911
1792
|
"list_threads",
|
|
1912
|
-
"List all subplot/storyline threads for a project. Returns a structured JSON envelope with results and total_count. Supports pagination via page/page_size.",
|
|
1793
|
+
"List all subplot/storyline threads for a project. Returns a structured JSON envelope with results and total_count. Use this to discover valid thread_id values before calling get_thread_arc or upsert_thread_link. Supports pagination via page/page_size.",
|
|
1913
1794
|
{
|
|
1914
1795
|
project_id: z.string().describe("Project ID."),
|
|
1915
1796
|
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
@@ -1937,7 +1818,7 @@ function createMcpServer() {
|
|
|
1937
1818
|
// ---- get_thread_arc ------------------------------------------------------
|
|
1938
1819
|
s.tool(
|
|
1939
1820
|
"get_thread_arc",
|
|
1940
|
-
"Get ordered scene metadata for all scenes belonging to a thread, including the per-thread beat. Returns a structured JSON envelope with thread metadata, results, and total_count. Supports pagination via page/page_size.",
|
|
1821
|
+
"Get ordered scene metadata for all scenes belonging to a thread, including the per-thread beat. Returns a structured JSON envelope with thread metadata, results, and total_count. Use list_threads first to find a valid thread_id, then call get_scene_prose for close reading of specific scenes. Supports pagination via page/page_size.",
|
|
1941
1822
|
{
|
|
1942
1823
|
thread_id: z.string().describe("Thread ID."),
|
|
1943
1824
|
page: z.number().int().min(1).optional().describe("Optional page number for paginated responses (1-based)."),
|
|
@@ -1946,7 +1827,7 @@ function createMcpServer() {
|
|
|
1946
1827
|
async ({ thread_id, page, page_size }) => {
|
|
1947
1828
|
const thread = db.prepare(`SELECT * FROM threads WHERE thread_id = ?`).get(thread_id);
|
|
1948
1829
|
if (!thread) {
|
|
1949
|
-
return errorResponse("NOT_FOUND", `Thread '${thread_id}' not found.`);
|
|
1830
|
+
return errorResponse("NOT_FOUND", `Thread '${thread_id}' not found. Hint: call list_threads with project_id to get valid thread IDs.`);
|
|
1950
1831
|
}
|
|
1951
1832
|
|
|
1952
1833
|
const rows = db.prepare(`
|
|
@@ -2326,7 +2207,7 @@ function createMcpServer() {
|
|
|
2326
2207
|
|
|
2327
2208
|
const scene = db.prepare(`SELECT file_path FROM scenes WHERE scene_id = ?`).get(scene_id);
|
|
2328
2209
|
if (!scene) {
|
|
2329
|
-
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found.`);
|
|
2210
|
+
return errorResponse("NOT_FOUND", `Scene '${scene_id}' not found. Hint: call find_scenes to get valid scene IDs.`);
|
|
2330
2211
|
}
|
|
2331
2212
|
|
|
2332
2213
|
try {
|
|
@@ -2397,7 +2278,7 @@ function createMcpServer() {
|
|
|
2397
2278
|
|
|
2398
2279
|
const proposal = pendingProposals.get(proposal_id);
|
|
2399
2280
|
if (!proposal) {
|
|
2400
|
-
return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired.`);
|
|
2281
|
+
return errorResponse("PROPOSAL_NOT_FOUND", `Proposal '${proposal_id}' not found or has expired. Hint: call propose_edit again to create a fresh proposal_id.`);
|
|
2401
2282
|
}
|
|
2402
2283
|
|
|
2403
2284
|
if (proposal.scene_id !== scene_id) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "1.11.
|
|
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
|
|
169
|
+
const baseErrorCode = error && typeof error === "object" && typeof error.code === "string"
|
|
169
170
|
? error.code
|
|
170
171
|
: "ASYNC_JOB_FAILED";
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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);
|