@hanna84/mcp-writing 2.12.4 → 2.12.5
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 +10 -0
- package/async-jobs.js +1 -218
- package/async-progress.js +1 -1
- package/index.js +1 -501
- package/package.json +32 -2
- package/runtime-diagnostics.js +1 -97
- package/scripts/generate-tool-docs.mjs +21 -3
- package/src/index.js +502 -0
- package/src/runtime/async-jobs.js +218 -0
- package/src/runtime/async-progress.js +1 -0
- package/src/runtime/runtime-diagnostics.js +97 -0
package/index.js
CHANGED
|
@@ -1,501 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import http from "node:http";
|
|
5
|
-
import fs from "node:fs";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { openDb, checkpointJobFinish, loadStalledJobs, pruneJobCheckpoints } from "./db.js";
|
|
10
|
-
import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, isStructuralProjectId } from "./sync.js";
|
|
11
|
-
import { isGitAvailable, isGitRepository, initGitRepository, getSceneProseAtCommit } from "./git.js";
|
|
12
|
-
import { createAsyncJobManager, readJsonIfExists } from "./async-jobs.js";
|
|
13
|
-
import {
|
|
14
|
-
createHelpers,
|
|
15
|
-
deriveLoglineFromProse,
|
|
16
|
-
inferCharacterIdsFromProse,
|
|
17
|
-
readSupportingNotesForEntity,
|
|
18
|
-
readEntityMetadata,
|
|
19
|
-
resolveBatchTargetScenes,
|
|
20
|
-
} from "./helpers.js";
|
|
21
|
-
import { STYLEGUIDE_CONFIG_BASENAME } from "./prose-styleguide.js";
|
|
22
|
-
import { registerSyncTools } from "./tools/sync.js";
|
|
23
|
-
import { registerSearchTools } from "./tools/search.js";
|
|
24
|
-
import { registerMetadataTools } from "./tools/metadata.js";
|
|
25
|
-
import { registerReviewBundleTools } from "./tools/review-bundles.js";
|
|
26
|
-
import { registerStyleguideTools } from "./tools/styleguide.js";
|
|
27
|
-
import { registerEditingTools } from "./tools/editing.js";
|
|
28
|
-
import { WORKFLOW_CATALOGUE } from "./workflow-catalogue.js";
|
|
29
|
-
import { getRuntimeDiagnostics } from "./runtime-diagnostics.js";
|
|
30
|
-
|
|
31
|
-
const SYNC_DIR = process.env.WRITING_SYNC_DIR ?? "./sync";
|
|
32
|
-
const DB_PATH = process.env.DB_PATH ?? "./writing.db";
|
|
33
|
-
const SYNC_DIR_ABS = path.resolve(SYNC_DIR);
|
|
34
|
-
const SYNC_DIR_REAL = (() => {
|
|
35
|
-
try {
|
|
36
|
-
return fs.realpathSync(SYNC_DIR_ABS);
|
|
37
|
-
} catch {
|
|
38
|
-
return SYNC_DIR_ABS;
|
|
39
|
-
}
|
|
40
|
-
})();
|
|
41
|
-
const DB_PATH_DISPLAY = DB_PATH === ":memory:" ? DB_PATH : path.resolve(DB_PATH);
|
|
42
|
-
|
|
43
|
-
function parsePositiveIntEnv(rawValue, defaultValue) {
|
|
44
|
-
const parsed = parseInt(rawValue ?? String(defaultValue), 10);
|
|
45
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function validateRegexPatterns(patterns) {
|
|
49
|
-
for (const pattern of patterns ?? []) {
|
|
50
|
-
try {
|
|
51
|
-
// Validation-only compile so async and sync paths share the same input contract.
|
|
52
|
-
new RegExp(pattern);
|
|
53
|
-
} catch (error) {
|
|
54
|
-
return {
|
|
55
|
-
ok: false,
|
|
56
|
-
pattern,
|
|
57
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return { ok: true };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3000", 10);
|
|
65
|
-
const MAX_CHAPTER_SCENES = parseInt(process.env.MAX_CHAPTER_SCENES ?? "10", 10);
|
|
66
|
-
const DEFAULT_METADATA_PAGE_SIZE = parseInt(process.env.DEFAULT_METADATA_PAGE_SIZE ?? "20", 10);
|
|
67
|
-
const ASYNC_JOB_TTL_MS = parsePositiveIntEnv(process.env.ASYNC_JOB_TTL_MS, 86400000);
|
|
68
|
-
// Maximum time to wait for running async jobs to complete before forcing process exit on SIGTERM/SIGINT.
|
|
69
|
-
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = parsePositiveIntEnv(process.env.GRACEFUL_SHUTDOWN_TIMEOUT_MS, 30000);
|
|
70
|
-
const OWNERSHIP_GUARD_MODE_RAW = (process.env.OWNERSHIP_GUARD_MODE ?? "warn").trim().toLowerCase();
|
|
71
|
-
const OWNERSHIP_GUARD_MODE = OWNERSHIP_GUARD_MODE_RAW === "fail" || OWNERSHIP_GUARD_MODE_RAW === "warn"
|
|
72
|
-
? OWNERSHIP_GUARD_MODE_RAW
|
|
73
|
-
: "warn";
|
|
74
|
-
const OWNERSHIP_GUARD_MODE_RAW_DISPLAY = JSON.stringify(OWNERSHIP_GUARD_MODE_RAW);
|
|
75
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
76
|
-
const __dirname = path.dirname(__filename);
|
|
77
|
-
const pkg = readJsonIfExists(path.join(__dirname, "package.json")) ?? {};
|
|
78
|
-
const MCP_SERVER_VERSION = typeof pkg.version === "string" && pkg.version.trim()
|
|
79
|
-
? pkg.version
|
|
80
|
-
: "0.0.0";
|
|
81
|
-
const asyncJobs = new Map();
|
|
82
|
-
|
|
83
|
-
function paginateRows(rows, { page, pageSize, forcePagination = false }) {
|
|
84
|
-
const totalCount = rows.length;
|
|
85
|
-
const shouldPaginate = forcePagination || page !== undefined || pageSize !== undefined;
|
|
86
|
-
|
|
87
|
-
if (!shouldPaginate) {
|
|
88
|
-
return {
|
|
89
|
-
paginated: false,
|
|
90
|
-
rows,
|
|
91
|
-
meta: null,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const safePageSize = Math.max(1, pageSize ?? DEFAULT_METADATA_PAGE_SIZE);
|
|
96
|
-
const safePage = Math.max(1, page ?? 1);
|
|
97
|
-
const totalPages = Math.max(1, Math.ceil(totalCount / safePageSize));
|
|
98
|
-
const normalizedPage = Math.min(safePage, totalPages);
|
|
99
|
-
const offset = (normalizedPage - 1) * safePageSize;
|
|
100
|
-
const pageRows = rows.slice(offset, offset + safePageSize);
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
paginated: true,
|
|
104
|
-
rows: pageRows,
|
|
105
|
-
meta: {
|
|
106
|
-
total_count: totalCount,
|
|
107
|
-
page: normalizedPage,
|
|
108
|
-
page_size: safePageSize,
|
|
109
|
-
total_pages: totalPages,
|
|
110
|
-
has_next_page: normalizedPage < totalPages,
|
|
111
|
-
has_prev_page: normalizedPage > 1,
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function jsonResponse(payload) {
|
|
117
|
-
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function errorResponse(code, message, details) {
|
|
121
|
-
const payload = {
|
|
122
|
-
ok: false,
|
|
123
|
-
error: {
|
|
124
|
-
code,
|
|
125
|
-
message,
|
|
126
|
-
...(details ? { details } : {}),
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
return jsonResponse(payload);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
// Database setup
|
|
134
|
-
// ---------------------------------------------------------------------------
|
|
135
|
-
const db = openDb(DB_PATH);
|
|
136
|
-
|
|
137
|
-
// Recover jobs that were in-flight when the server last exited.
|
|
138
|
-
const stalledJobs = loadStalledJobs(db);
|
|
139
|
-
for (const job of stalledJobs) {
|
|
140
|
-
job.status = "failed";
|
|
141
|
-
job.error = "server restarted while job was running";
|
|
142
|
-
job.finishedAt = new Date().toISOString();
|
|
143
|
-
try {
|
|
144
|
-
checkpointJobFinish(db, job);
|
|
145
|
-
} catch (err) {
|
|
146
|
-
process.stderr.write(`[mcp-writing] WARNING: failed to checkpoint recovered stalled job ${job.id}: ${err.message}\n`);
|
|
147
|
-
}
|
|
148
|
-
asyncJobs.set(job.id, job);
|
|
149
|
-
}
|
|
150
|
-
// Prune expired rows from previous sessions unconditionally — completed/failed
|
|
151
|
-
// jobs from prior runs are never loaded into asyncJobs, so anyPruned in
|
|
152
|
-
// pruneAsyncJobs() would never be true for them.
|
|
153
|
-
try { pruneJobCheckpoints(db, ASYNC_JOB_TTL_MS); } catch { /* best effort */ }
|
|
154
|
-
|
|
155
|
-
if (stalledJobs.length > 0) {
|
|
156
|
-
process.stderr.write(`[mcp-writing] Marked ${stalledJobs.length} stalled job(s) as failed after restart.\n`);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const { pruneAsyncJobs, startAsyncJob, toPublicJob } = createAsyncJobManager({
|
|
160
|
-
db,
|
|
161
|
-
asyncJobs,
|
|
162
|
-
ttlMs: ASYNC_JOB_TTL_MS,
|
|
163
|
-
runnerDir: __dirname,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
process.stderr.write(`[mcp-writing] Sync dir: ${SYNC_DIR_ABS}\n`);
|
|
167
|
-
process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
|
|
168
|
-
|
|
169
|
-
// Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
|
|
170
|
-
const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
|
|
171
|
-
const SYNC_OWNERSHIP_DIAGNOSTICS = getSyncOwnershipDiagnostics(SYNC_DIR);
|
|
172
|
-
if (!SYNC_DIR_WRITABLE) {
|
|
173
|
-
process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Check git availability and initialize repository if needed (Phase 3)
|
|
177
|
-
const GIT_AVAILABLE = isGitAvailable();
|
|
178
|
-
let GIT_ENABLED = false;
|
|
179
|
-
if (GIT_AVAILABLE && SYNC_DIR_WRITABLE) {
|
|
180
|
-
if (!isGitRepository(SYNC_DIR)) {
|
|
181
|
-
try {
|
|
182
|
-
initGitRepository(SYNC_DIR);
|
|
183
|
-
process.stderr.write(`[mcp-writing] Initialized git repository at ${SYNC_DIR}\n`);
|
|
184
|
-
GIT_ENABLED = true;
|
|
185
|
-
} catch (err) {
|
|
186
|
-
process.stderr.write(`[mcp-writing] WARNING: Failed to initialize git repository: ${err.message}\n`);
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
GIT_ENABLED = true;
|
|
190
|
-
process.stderr.write(`[mcp-writing] Git repository detected at ${SYNC_DIR} — Phase 3 editing tools enabled\n`);
|
|
191
|
-
}
|
|
192
|
-
} else if (!GIT_AVAILABLE) {
|
|
193
|
-
process.stderr.write(`[mcp-writing] WARNING: git not found on PATH — Phase 3 editing tools will be unavailable\n`);
|
|
194
|
-
} else if (!SYNC_DIR_WRITABLE) {
|
|
195
|
-
process.stderr.write(`[mcp-writing] NOTE: sync dir is read-only — Phase 3 editing tools will be unavailable\n`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// In-memory storage for pending edit proposals (Phase 3)
|
|
199
|
-
const pendingProposals = new Map();
|
|
200
|
-
function generateProposalId() {
|
|
201
|
-
return `proposal-${randomUUID()}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const RUNTIME_DIAGNOSTICS = getRuntimeDiagnostics({
|
|
205
|
-
ownershipGuardModeRaw: OWNERSHIP_GUARD_MODE_RAW,
|
|
206
|
-
ownershipGuardMode: OWNERSHIP_GUARD_MODE,
|
|
207
|
-
ownershipGuardModeRawDisplay: OWNERSHIP_GUARD_MODE_RAW_DISPLAY,
|
|
208
|
-
syncDirWritable: SYNC_DIR_WRITABLE,
|
|
209
|
-
syncDirAbs: SYNC_DIR_ABS,
|
|
210
|
-
syncOwnershipDiagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
|
|
211
|
-
gitAvailable: GIT_AVAILABLE,
|
|
212
|
-
gitEnabled: GIT_ENABLED,
|
|
213
|
-
});
|
|
214
|
-
if (RUNTIME_DIAGNOSTICS.warnings.length) {
|
|
215
|
-
process.stderr.write(`[mcp-writing] Runtime diagnostics:\n`);
|
|
216
|
-
for (const line of RUNTIME_DIAGNOSTICS.warnings) {
|
|
217
|
-
process.stderr.write(`[mcp-writing] - ${line}\n`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const SHOULD_ENFORCE_OWNERSHIP_FAIL_GUARD = OWNERSHIP_GUARD_MODE === "fail"
|
|
222
|
-
&& SYNC_OWNERSHIP_DIAGNOSTICS.supported
|
|
223
|
-
&& SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid !== 0;
|
|
224
|
-
|
|
225
|
-
if (SHOULD_ENFORCE_OWNERSHIP_FAIL_GUARD && SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths > 0) {
|
|
226
|
-
process.stderr.write(
|
|
227
|
-
`[mcp-writing] FATAL: OWNERSHIP_GUARD_MODE=fail and ${SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid}.\n`
|
|
228
|
-
);
|
|
229
|
-
process.stderr.write(
|
|
230
|
-
`[mcp-writing] FATAL: Repair ownership once on the host directory mounted at ${SYNC_DIR_ABS}: sudo chown -R "$(id -u):$(id -g)" /path/to/host-sync-dir\n`
|
|
231
|
-
);
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const {
|
|
236
|
-
isPathInsideSyncDir,
|
|
237
|
-
isPathCandidateInsideSyncDir,
|
|
238
|
-
resolveOutputDirWithinSync,
|
|
239
|
-
resolveProjectRoot,
|
|
240
|
-
createCanonicalWorldEntity,
|
|
241
|
-
} = createHelpers({
|
|
242
|
-
syncDir: SYNC_DIR,
|
|
243
|
-
syncDirReal: SYNC_DIR_REAL,
|
|
244
|
-
syncDirAbs: SYNC_DIR_ABS,
|
|
245
|
-
db,
|
|
246
|
-
syncDirWritable: SYNC_DIR_WRITABLE,
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
// Run sync on startup
|
|
250
|
-
syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
|
|
251
|
-
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
|
-
// Graceful shutdown — drain running async jobs before exit
|
|
254
|
-
// ---------------------------------------------------------------------------
|
|
255
|
-
async function waitForRunningJobs() {
|
|
256
|
-
const running = [...asyncJobs.values()].filter(
|
|
257
|
-
(j) => j.status === "running" || j.status === "cancelling"
|
|
258
|
-
);
|
|
259
|
-
if (!running.length) return;
|
|
260
|
-
|
|
261
|
-
process.stderr.write(
|
|
262
|
-
`[mcp-writing] Waiting for ${running.length} async job(s) to finish (max ${GRACEFUL_SHUTDOWN_TIMEOUT_MS / 1000}s)...\n`
|
|
263
|
-
);
|
|
264
|
-
const deadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT_MS;
|
|
265
|
-
return new Promise((resolve) => {
|
|
266
|
-
const check = () => {
|
|
267
|
-
const stillRunning = [...asyncJobs.values()].filter(
|
|
268
|
-
(j) => j.status === "running" || j.status === "cancelling"
|
|
269
|
-
);
|
|
270
|
-
if (!stillRunning.length) {
|
|
271
|
-
resolve();
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
if (Date.now() >= deadline) {
|
|
275
|
-
process.stderr.write(
|
|
276
|
-
`[mcp-writing] Shutdown timeout: force-killing ${stillRunning.length} remaining job(s).\n`
|
|
277
|
-
);
|
|
278
|
-
for (const job of stillRunning) {
|
|
279
|
-
try { job.child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
280
|
-
}
|
|
281
|
-
resolve();
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
setTimeout(check, 200);
|
|
285
|
-
};
|
|
286
|
-
check();
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
async function gracefulShutdown(signal) {
|
|
291
|
-
process.stderr.write(`[mcp-writing] Received ${signal}, shutting down gracefully.\n`);
|
|
292
|
-
await waitForRunningJobs();
|
|
293
|
-
process.exit(0);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function maxScenesNextStep(matchedCount) {
|
|
297
|
-
return `Re-run with max_scenes set to at least ${matchedCount}.`;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
// MCP server factory
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
function createMcpServer() {
|
|
304
|
-
const s = new McpServer({ name: "mcp-writing", version: MCP_SERVER_VERSION });
|
|
305
|
-
|
|
306
|
-
// ---- describe_workflows --------------------------------------------------
|
|
307
|
-
s.tool(
|
|
308
|
-
"describe_workflows",
|
|
309
|
-
"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.",
|
|
310
|
-
{},
|
|
311
|
-
async () => {
|
|
312
|
-
const projectRow = db.prepare(
|
|
313
|
-
`SELECT project_id FROM scenes GROUP BY project_id ORDER BY COUNT(*) DESC, project_id ASC LIMIT 1`
|
|
314
|
-
).get();
|
|
315
|
-
// Suppress structural-dir names (e.g. "scenes") that appear when SYNC_DIR points at the
|
|
316
|
-
// project directory itself rather than the universe root. They are path artifacts, not
|
|
317
|
-
// real project identifiers. Only suppress when no real project directory exists at that
|
|
318
|
-
// path, so a project intentionally named "scenes" (though inadvisable) is still honoured.
|
|
319
|
-
const rawProjectId = projectRow?.project_id ?? null;
|
|
320
|
-
const rawProjectRootPath = rawProjectId ? resolveProjectRoot(rawProjectId) : null;
|
|
321
|
-
const project_id = (
|
|
322
|
-
isStructuralProjectId(rawProjectId) && !fs.existsSync(rawProjectRootPath)
|
|
323
|
-
) ? null : rawProjectId;
|
|
324
|
-
|
|
325
|
-
const sceneCountRow = db.prepare(`SELECT COUNT(*) as count FROM scenes`).get();
|
|
326
|
-
const scene_count = sceneCountRow?.count ?? 0;
|
|
327
|
-
|
|
328
|
-
const syncRootConfigPath = path.join(SYNC_DIR, STYLEGUIDE_CONFIG_BASENAME);
|
|
329
|
-
const projectRootConfigPath = project_id
|
|
330
|
-
? path.join(resolveProjectRoot(project_id), STYLEGUIDE_CONFIG_BASENAME)
|
|
331
|
-
: null;
|
|
332
|
-
const universeSegment = project_id?.includes("/") ? project_id.split("/")[0] : null;
|
|
333
|
-
const universeRootConfigPath = universeSegment
|
|
334
|
-
? path.join(SYNC_DIR, "universes", universeSegment, STYLEGUIDE_CONFIG_BASENAME)
|
|
335
|
-
: null;
|
|
336
|
-
|
|
337
|
-
const syncRootExists = fs.existsSync(syncRootConfigPath);
|
|
338
|
-
const universeRootExists = universeRootConfigPath !== null && fs.existsSync(universeRootConfigPath);
|
|
339
|
-
const projectRootExists = projectRootConfigPath !== null && fs.existsSync(projectRootConfigPath);
|
|
340
|
-
|
|
341
|
-
return jsonResponse({
|
|
342
|
-
ok: true,
|
|
343
|
-
context: {
|
|
344
|
-
project_id,
|
|
345
|
-
scene_count,
|
|
346
|
-
sync_dir: SYNC_DIR_ABS,
|
|
347
|
-
styleguide_exists: {
|
|
348
|
-
sync_root: syncRootExists,
|
|
349
|
-
universe_root: universeRootExists,
|
|
350
|
-
project_root: projectRootExists,
|
|
351
|
-
},
|
|
352
|
-
git_available: GIT_AVAILABLE,
|
|
353
|
-
pending_proposals: pendingProposals.size,
|
|
354
|
-
},
|
|
355
|
-
workflows: WORKFLOW_CATALOGUE,
|
|
356
|
-
notes: [
|
|
357
|
-
"Never write JavaScript or shell scripts to invoke tools. Call them directly.",
|
|
358
|
-
"If a tool returns a next_step field (in a success or error response), follow it before trying anything else.",
|
|
359
|
-
"Use find_scenes without filters to discover what project_ids are indexed.",
|
|
360
|
-
"When calling bootstrap_prose_styleguide_config or check_prose_styleguide_drift, set max_scenes to context.scene_count to avoid the default limit.",
|
|
361
|
-
"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.",
|
|
362
|
-
],
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
);
|
|
366
|
-
|
|
367
|
-
// Passed to each tool registration module (tools/*.js) to thread state and
|
|
368
|
-
// shared helpers without circular imports. Grows as groups are extracted.
|
|
369
|
-
const toolContext = {
|
|
370
|
-
db,
|
|
371
|
-
SYNC_DIR,
|
|
372
|
-
SYNC_DIR_ABS,
|
|
373
|
-
SYNC_DIR_REAL,
|
|
374
|
-
SYNC_DIR_WRITABLE,
|
|
375
|
-
GIT_ENABLED,
|
|
376
|
-
asyncJobs,
|
|
377
|
-
errorResponse,
|
|
378
|
-
jsonResponse,
|
|
379
|
-
validateRegexPatterns,
|
|
380
|
-
startAsyncJob,
|
|
381
|
-
pruneAsyncJobs,
|
|
382
|
-
toPublicJob,
|
|
383
|
-
resolveProjectRoot,
|
|
384
|
-
resolveBatchTargetScenes,
|
|
385
|
-
maxScenesNextStep,
|
|
386
|
-
isPathInsideSyncDir,
|
|
387
|
-
deriveLoglineFromProse,
|
|
388
|
-
inferCharacterIdsFromProse,
|
|
389
|
-
paginateRows,
|
|
390
|
-
DEFAULT_METADATA_PAGE_SIZE,
|
|
391
|
-
MAX_CHAPTER_SCENES,
|
|
392
|
-
getSceneProseAtCommit,
|
|
393
|
-
readSupportingNotesForEntity,
|
|
394
|
-
readEntityMetadata,
|
|
395
|
-
createCanonicalWorldEntity,
|
|
396
|
-
resolveOutputDirWithinSync,
|
|
397
|
-
isPathCandidateInsideSyncDir,
|
|
398
|
-
pendingProposals,
|
|
399
|
-
generateProposalId,
|
|
400
|
-
};
|
|
401
|
-
registerSyncTools(s, toolContext);
|
|
402
|
-
registerSearchTools(s, toolContext);
|
|
403
|
-
registerMetadataTools(s, toolContext);
|
|
404
|
-
registerReviewBundleTools(s, toolContext);
|
|
405
|
-
registerStyleguideTools(s, toolContext);
|
|
406
|
-
registerEditingTools(s, toolContext);
|
|
407
|
-
|
|
408
|
-
// ---- get_runtime_config --------------------------------------------------
|
|
409
|
-
s.tool(
|
|
410
|
-
"get_runtime_config",
|
|
411
|
-
"Show the active runtime paths and capabilities for this server instance (server version, sync dir, database path, writability, permission diagnostics, and git availability). Use this to verify which manuscript location is currently connected.",
|
|
412
|
-
{},
|
|
413
|
-
async () => {
|
|
414
|
-
return jsonResponse({
|
|
415
|
-
server_version: MCP_SERVER_VERSION,
|
|
416
|
-
sync_dir: SYNC_DIR_ABS,
|
|
417
|
-
db_path: DB_PATH_DISPLAY,
|
|
418
|
-
sync_dir_writable: SYNC_DIR_WRITABLE,
|
|
419
|
-
ownership_guard_mode: OWNERSHIP_GUARD_MODE,
|
|
420
|
-
permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
|
|
421
|
-
git_available: GIT_AVAILABLE,
|
|
422
|
-
git_enabled: GIT_ENABLED,
|
|
423
|
-
http_port: HTTP_PORT,
|
|
424
|
-
runtime_warnings: RUNTIME_DIAGNOSTICS.warnings,
|
|
425
|
-
setup_recommendations: RUNTIME_DIAGNOSTICS.recommendations,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
// ---- prose styleguide ---------------------------------------------------
|
|
431
|
-
return s;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ---------------------------------------------------------------------------
|
|
435
|
-
// Transport startup
|
|
436
|
-
// ---------------------------------------------------------------------------
|
|
437
|
-
const MCP_TRANSPORT = (process.env.MCP_TRANSPORT ?? "http").trim().toLowerCase();
|
|
438
|
-
|
|
439
|
-
if (MCP_TRANSPORT === "stdio") {
|
|
440
|
-
const stdioServer = createMcpServer();
|
|
441
|
-
const stdioTransport = new StdioServerTransport();
|
|
442
|
-
await stdioServer.connect(stdioTransport);
|
|
443
|
-
process.stderr.write("[mcp-writing] Running in stdio transport mode\n");
|
|
444
|
-
} else {
|
|
445
|
-
// ---------------------------------------------------------------------------
|
|
446
|
-
// HTTP server
|
|
447
|
-
// ---------------------------------------------------------------------------
|
|
448
|
-
const activeSessions = new Map();
|
|
449
|
-
|
|
450
|
-
const httpServer = http.createServer(async (req, res) => {
|
|
451
|
-
if (req.method === "GET" && req.url === "/sse") {
|
|
452
|
-
const transport = new SSEServerTransport("/message", res);
|
|
453
|
-
const sessionId = transport.sessionId;
|
|
454
|
-
|
|
455
|
-
const existing = activeSessions.get(sessionId);
|
|
456
|
-
if (existing) {
|
|
457
|
-
try { await existing.transport.close(); } catch { /* empty */ }
|
|
458
|
-
try { await existing.server.close(); } catch { /* empty */ }
|
|
459
|
-
activeSessions.delete(sessionId);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
const sessionServer = createMcpServer();
|
|
463
|
-
activeSessions.set(sessionId, { transport, server: sessionServer });
|
|
464
|
-
res.on("close", () => activeSessions.delete(sessionId));
|
|
465
|
-
|
|
466
|
-
await sessionServer.connect(transport);
|
|
467
|
-
process.stderr.write(`[mcp-writing] SSE client connected (session=${sessionId})\n`);
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (req.method === "POST" && req.url.startsWith("/message")) {
|
|
472
|
-
const url = new URL(req.url, `http://localhost`);
|
|
473
|
-
const sessionId = url.searchParams.get("sessionId");
|
|
474
|
-
const session = sessionId ? activeSessions.get(sessionId) : null;
|
|
475
|
-
if (!session) {
|
|
476
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
477
|
-
res.end("Session not found");
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
await session.transport.handlePostMessage(req, res);
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (req.method === "GET" && req.url === "/healthz") {
|
|
485
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
486
|
-
res.end("ok");
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
491
|
-
res.end("Not found");
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
httpServer.listen(HTTP_PORT, () => {
|
|
495
|
-
process.stderr.write(`[mcp-writing] Listening on port ${HTTP_PORT}\n`);
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Register after transport setup so signal handlers can reference asyncJobs.
|
|
500
|
-
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
|
|
501
|
-
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
|
|
1
|
+
export * from "./src/index.js";
|
package/package.json
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanna84/mcp-writing",
|
|
3
|
-
"version": "2.12.
|
|
3
|
+
"version": "2.12.5",
|
|
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",
|
|
7
7
|
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./index.js": "./index.js",
|
|
11
|
+
"./async-jobs.js": "./async-jobs.js",
|
|
12
|
+
"./async-progress.js": "./async-progress.js",
|
|
13
|
+
"./helpers.js": "./helpers.js",
|
|
14
|
+
"./scene-character-batch.js": "./scene-character-batch.js",
|
|
15
|
+
"./scrivener-direct.js": "./scrivener-direct.js",
|
|
16
|
+
"./importer.js": "./importer.js",
|
|
17
|
+
"./db.js": "./db.js",
|
|
18
|
+
"./sync.js": "./sync.js",
|
|
19
|
+
"./git.js": "./git.js",
|
|
20
|
+
"./world-entity-templates.js": "./world-entity-templates.js",
|
|
21
|
+
"./workflow-catalogue.js": "./workflow-catalogue.js",
|
|
22
|
+
"./runtime-diagnostics.js": "./runtime-diagnostics.js",
|
|
23
|
+
"./metadata-lint.js": "./metadata-lint.js",
|
|
24
|
+
"./scene-character-normalization.js": "./scene-character-normalization.js",
|
|
25
|
+
"./review-bundles.js": "./review-bundles.js",
|
|
26
|
+
"./review-bundles-planner.js": "./review-bundles-planner.js",
|
|
27
|
+
"./review-bundles-renderer.js": "./review-bundles-renderer.js",
|
|
28
|
+
"./review-bundles-writer.js": "./review-bundles-writer.js",
|
|
29
|
+
"./prose-styleguide.js": "./prose-styleguide.js",
|
|
30
|
+
"./prose-styleguide-drift.js": "./prose-styleguide-drift.js",
|
|
31
|
+
"./prose-styleguide-skill.js": "./prose-styleguide-skill.js",
|
|
32
|
+
"./tools/*": "./tools/*",
|
|
33
|
+
"./scripts/*": "./scripts/*",
|
|
34
|
+
"./src/*": "./src/*",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
8
37
|
"bin": {
|
|
9
38
|
"mcp-writing": "./bin/mcp-writing.js"
|
|
10
39
|
},
|
|
@@ -12,6 +41,7 @@
|
|
|
12
41
|
"files": [
|
|
13
42
|
"bin/",
|
|
14
43
|
"index.js",
|
|
44
|
+
"src/",
|
|
15
45
|
"async-jobs.js",
|
|
16
46
|
"async-progress.js",
|
|
17
47
|
"helpers.js",
|
|
@@ -52,7 +82,7 @@
|
|
|
52
82
|
"normalize:scene-characters": "node --experimental-sqlite scripts/normalize-scene-characters.mjs",
|
|
53
83
|
"setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
|
|
54
84
|
"release": "release-it",
|
|
55
|
-
"lint": "eslint *.js scripts/ tools/",
|
|
85
|
+
"lint": "eslint *.js src/ scripts/ tools/",
|
|
56
86
|
"docs": "node scripts/generate-tool-docs.mjs",
|
|
57
87
|
"lint:metadata": "node scripts/lint-metadata.mjs",
|
|
58
88
|
"sync:server-json-version": "node scripts/sync-server-json-version.mjs",
|
package/runtime-diagnostics.js
CHANGED
|
@@ -1,97 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* getRuntimeDiagnostics
|
|
3
|
-
*
|
|
4
|
-
* Inspects the startup environment and returns { warnings, recommendations }.
|
|
5
|
-
* All inputs are passed explicitly so this module has no side effects and
|
|
6
|
-
* is straightforward to test.
|
|
7
|
-
*
|
|
8
|
-
* @param {object} opts
|
|
9
|
-
* @param {string} opts.ownershipGuardModeRaw Raw env value before normalisation
|
|
10
|
-
* @param {string} opts.ownershipGuardMode Normalised value ("warn" | "fail")
|
|
11
|
-
* @param {string} opts.ownershipGuardModeRawDisplay JSON.stringify of the raw value
|
|
12
|
-
* @param {boolean} opts.syncDirWritable
|
|
13
|
-
* @param {string} opts.syncDirAbs Resolved absolute path shown in messages
|
|
14
|
-
* @param {object} opts.syncOwnershipDiagnostics Result of getSyncOwnershipDiagnostics()
|
|
15
|
-
* @param {boolean} opts.gitAvailable
|
|
16
|
-
* @param {boolean} opts.gitEnabled
|
|
17
|
-
* @returns {{ warnings: string[], recommendations: string[] }}
|
|
18
|
-
*/
|
|
19
|
-
export function getRuntimeDiagnostics({
|
|
20
|
-
ownershipGuardModeRaw,
|
|
21
|
-
ownershipGuardMode,
|
|
22
|
-
ownershipGuardModeRawDisplay,
|
|
23
|
-
syncDirWritable,
|
|
24
|
-
syncDirAbs,
|
|
25
|
-
syncOwnershipDiagnostics,
|
|
26
|
-
gitAvailable,
|
|
27
|
-
gitEnabled,
|
|
28
|
-
}) {
|
|
29
|
-
const warnings = [];
|
|
30
|
-
const recommendations = [];
|
|
31
|
-
|
|
32
|
-
if (ownershipGuardModeRaw !== ownershipGuardMode) {
|
|
33
|
-
warnings.push(
|
|
34
|
-
`OWNERSHIP_GUARD_MODE_INVALID: Unsupported OWNERSHIP_GUARD_MODE=${ownershipGuardModeRawDisplay}. Falling back to 'warn'.`
|
|
35
|
-
);
|
|
36
|
-
recommendations.push("Set OWNERSHIP_GUARD_MODE to either 'warn' or 'fail'.");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (syncOwnershipDiagnostics.runtime_uid_override_ignored) {
|
|
40
|
-
warnings.push("RUNTIME_UID_OVERRIDE_IGNORED: RUNTIME_UID_OVERRIDE is ignored unless NODE_ENV=test or ALLOW_RUNTIME_UID_OVERRIDE=1.");
|
|
41
|
-
recommendations.push("Avoid RUNTIME_UID_OVERRIDE in production runtime environments.");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (syncOwnershipDiagnostics.runtime_uid_override_invalid) {
|
|
45
|
-
warnings.push("RUNTIME_UID_OVERRIDE_INVALID: RUNTIME_UID_OVERRIDE must be a non-negative integer when enabled.");
|
|
46
|
-
recommendations.push("Set RUNTIME_UID_OVERRIDE to a non-negative integer, or unset it.");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (!syncDirWritable) {
|
|
50
|
-
warnings.push("SYNC_DIR_READ_ONLY: sync dir is read-only; metadata write-back and prose editing tools are unavailable.");
|
|
51
|
-
recommendations.push("Mount WRITING_SYNC_DIR with write access (avoid read-only mounts like ':ro').");
|
|
52
|
-
recommendations.push("If running in Docker/OpenClaw, verify volume ownership and permissions for the container user.");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (syncOwnershipDiagnostics.supported && syncOwnershipDiagnostics.non_runtime_owned_paths > 0) {
|
|
56
|
-
warnings.push(
|
|
57
|
-
`OWNERSHIP_MISMATCH: ${syncOwnershipDiagnostics.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${syncOwnershipDiagnostics.runtime_uid}.`
|
|
58
|
-
);
|
|
59
|
-
recommendations.push(
|
|
60
|
-
`Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${syncDirAbs}"`
|
|
61
|
-
);
|
|
62
|
-
recommendations.push(
|
|
63
|
-
"For Docker/OpenClaw, run container as host user (compose: user: \"${OPENCLAW_UID:-1000}:${OPENCLAW_GID:-1000}\")."
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (ownershipGuardMode === "fail" && syncOwnershipDiagnostics.runtime_uid === 0) {
|
|
68
|
-
warnings.push(
|
|
69
|
-
"OWNERSHIP_GUARD_SKIPPED_FOR_ROOT: OWNERSHIP_GUARD_MODE=fail is skipped because runtime UID is 0 (root)."
|
|
70
|
-
);
|
|
71
|
-
recommendations.push("Prefer running as a non-root host-mapped UID/GID to make ownership guard checks meaningful.");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (syncOwnershipDiagnostics.supported && syncOwnershipDiagnostics.root_owned_paths > 0) {
|
|
75
|
-
warnings.push(
|
|
76
|
-
`ROOT_OWNED_PATHS: ${syncOwnershipDiagnostics.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!gitAvailable) {
|
|
81
|
-
warnings.push("GIT_NOT_FOUND: git is not available on PATH; snapshot/edit tools are unavailable.");
|
|
82
|
-
recommendations.push("Install git in the runtime image/environment.");
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (gitAvailable && syncDirWritable && !gitEnabled) {
|
|
86
|
-
warnings.push("GIT_DISABLED: git is available but repository snapshot tools are not active.");
|
|
87
|
-
recommendations.push("Ensure WRITING_SYNC_DIR points to a writable git repository root, or allow mcp-writing to initialize one.");
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (gitAvailable && !syncDirWritable) {
|
|
91
|
-
recommendations.push("If git reports 'dubious ownership' for mounted repos, add: git config --system --add safe.directory /sync");
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
recommendations.push("If indexing finds many files without scene_id, run scripts/import.js first for Scrivener Draft exports, then run sync.");
|
|
95
|
-
|
|
96
|
-
return { warnings, recommendations };
|
|
97
|
-
}
|
|
1
|
+
export * from "./src/runtime/runtime-diagnostics.js";
|