@hanna84/mcp-writing 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,11 +4,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.6.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.5.2...v1.6.0)
9
+
10
+ - feat: add permission preflight diagnostics and ops contract [`#45`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/45)
12
+
13
+ #### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.5.1...v1.5.2)
15
+
16
+ > 19 April 2026
17
+
18
+ - fix: avoid nested scenes/projects paths on import [`#44`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/44)
20
+ - Release 1.5.2 [`47f7c6c`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/47f7c6c9ddbc4244b8a62b1dc37c86eb914aafbc)
22
+
7
23
  #### [v1.5.1](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.5.0...v1.5.1)
9
25
 
26
+ > 19 April 2026
27
+
10
28
  - chore: switch license from MIT to AGPL v3 [`#43`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/43)
30
+ - Release 1.5.1 [`73b233f`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/73b233f2f210d435386f1235aa15ebc3c945542b)
12
32
 
13
33
  #### [v1.5.0](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.4.8...v1.5.0)
package/README.md CHANGED
@@ -34,6 +34,24 @@ npm --version # should be 8.0.0 or later
34
34
  git --version # should be installed
35
35
  ```
36
36
 
37
+ ## Permission Contract (Recommended For All Users)
38
+
39
+ To keep MCP write tools reliable across local runs, Docker, and AI agents, use this contract:
40
+
41
+ 1. The same non-root user should own and write the sync directory.
42
+ 2. Containerized runs should use host UID/GID, not root.
43
+ 3. If ownership drifts (for example root-owned files), repair once on host and continue.
44
+
45
+ Repair commands (host):
46
+
47
+ ```sh
48
+ sudo chown -R "$(id -u):$(id -g)" /path/to/sync-dir
49
+ find /path/to/sync-dir -type d -exec chmod u+rwx {} +
50
+ find /path/to/sync-dir -type f -exec chmod u+rw {} +
51
+ ```
52
+
53
+ You can also inspect ownership/writability status at runtime via `get_runtime_config`.
54
+
37
55
  ## First-time setup path (recommended)
38
56
 
39
57
  If this is your first time, follow these steps in order:
@@ -321,6 +339,7 @@ Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `se
321
339
  # docker-compose.yml snippet
322
340
  writing-mcp:
323
341
  build: .
342
+ user: "${UID:-1000}:${GID:-1000}"
324
343
  environment:
325
344
  WRITING_SYNC_DIR: /sync
326
345
  DB_PATH: /data/writing.db
@@ -338,6 +357,8 @@ volumes:
338
357
  writing-mcp-data:
339
358
  ```
340
359
 
360
+ If you want explicit host mapping, set `UID` and `GID` in your shell or a `.env` file next to `docker-compose.yml`.
361
+
341
362
  Then register in your OpenClaw config:
342
363
 
343
364
  ```json
package/importer.js CHANGED
@@ -129,6 +129,57 @@ function removeIfExists(filePath) {
129
129
  if (filePath && fs.existsSync(filePath)) fs.unlinkSync(filePath);
130
130
  }
131
131
 
132
+ function resolveSyncRootFromPrefix(prefix, syncDirAbs) {
133
+ const parsedRoot = path.parse(syncDirAbs).root;
134
+
135
+ if (!prefix) {
136
+ return parsedRoot ? path.resolve(parsedRoot) : path.resolve(syncDirAbs);
137
+ }
138
+
139
+ // On Windows, a regex prefix like "C:" would resolve relative to cwd on drive C.
140
+ // Use the true drive root instead (e.g., "C:\\").
141
+ if (/^[a-zA-Z]:$/.test(prefix)) {
142
+ return parsedRoot || `${prefix}${path.sep}`;
143
+ }
144
+
145
+ return path.resolve(prefix);
146
+ }
147
+
148
+ function detectScopedSyncDir(syncDirAbs) {
149
+ const normalized = syncDirAbs.split(path.sep).join("/");
150
+
151
+ const universeMatch = normalized.match(/^(.*)\/universes\/([^/]+)\/([^/]+)(?:\/scenes)?$/);
152
+ if (universeMatch) {
153
+ const prefix = universeMatch[1];
154
+ const universeId = universeMatch[2];
155
+ const projectSlug = universeMatch[3];
156
+ const syncRoot = resolveSyncRootFromPrefix(prefix, syncDirAbs);
157
+ const projectRoot = path.join(syncRoot, "universes", universeId, projectSlug);
158
+ return {
159
+ projectId: `${universeId}/${projectSlug}`,
160
+ scope: "universe",
161
+ syncRoot,
162
+ projectRoot,
163
+ };
164
+ }
165
+
166
+ const projectMatch = normalized.match(/^(.*)\/projects\/([^/]+)(?:\/scenes)?$/);
167
+ if (projectMatch) {
168
+ const prefix = projectMatch[1];
169
+ const projectSlug = projectMatch[2];
170
+ const syncRoot = resolveSyncRootFromPrefix(prefix, syncDirAbs);
171
+ const projectRoot = path.join(syncRoot, "projects", projectSlug);
172
+ return {
173
+ projectId: projectSlug,
174
+ scope: "project",
175
+ syncRoot,
176
+ projectRoot,
177
+ };
178
+ }
179
+
180
+ return null;
181
+ }
182
+
132
183
  export function importScrivenerSync({
133
184
  scrivenerDir,
134
185
  mcpSyncDir,
@@ -138,31 +189,48 @@ export function importScrivenerSync({
138
189
  }) {
139
190
  const scrivenerDirAbs = path.resolve(scrivenerDir);
140
191
  const mcpSyncDirAbs = path.resolve(mcpSyncDir);
192
+ const scopedSyncDir = detectScopedSyncDir(mcpSyncDirAbs);
193
+ const fallbackProjectId = path.basename(mcpSyncDirAbs).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
141
194
  const resolvedProjectId = projectId
142
195
  ? projectId
143
- : path.basename(mcpSyncDirAbs).replace(/[^a-z0-9-]/gi, "-").toLowerCase();
196
+ : scopedSyncDir?.projectId ?? fallbackProjectId;
144
197
 
145
198
  const projectIdCheck = validateProjectId(resolvedProjectId);
146
199
  if (!projectIdCheck.ok) {
147
200
  throw new Error(`Invalid project_id '${resolvedProjectId}': ${projectIdCheck.reason}`);
148
201
  }
149
202
 
203
+ if (scopedSyncDir && projectId && projectId !== scopedSyncDir.projectId) {
204
+ throw new Error(
205
+ `project_id '${projectId}' does not match WRITING_SYNC_DIR scope '${scopedSyncDir.projectId}'. `
206
+ + "Set WRITING_SYNC_DIR to the sync root or use the matching project_id."
207
+ );
208
+ }
209
+
150
210
  if (!fs.existsSync(scrivenerDirAbs)) {
151
211
  throw new Error(`Scrivener sync dir not found: ${scrivenerDirAbs}`);
152
212
  }
153
213
 
154
- // Route universe/project IDs to universes/<universe>/<project>/scenes,
155
- // matching the convention used by inferProjectAndUniverse in sync.js.
156
- const segments = resolvedProjectId.split("/");
157
214
  let scenesDir;
158
215
  let scenesBoundaryRoot;
159
- if (segments.length === 2) {
160
- const [universeId, projectSlug] = segments;
161
- scenesBoundaryRoot = path.join(mcpSyncDirAbs, "universes");
162
- scenesDir = path.resolve(scenesBoundaryRoot, universeId, projectSlug, "scenes");
216
+ if (scopedSyncDir) {
217
+ scenesBoundaryRoot = path.join(
218
+ scopedSyncDir.syncRoot,
219
+ scopedSyncDir.scope === "universe" ? "universes" : "projects"
220
+ );
221
+ scenesDir = path.join(scopedSyncDir.projectRoot, "scenes");
163
222
  } else {
164
- scenesBoundaryRoot = path.join(mcpSyncDirAbs, "projects");
165
- scenesDir = path.resolve(scenesBoundaryRoot, resolvedProjectId, "scenes");
223
+ // Route universe/project IDs to universes/<universe>/<project>/scenes,
224
+ // matching the convention used by inferProjectAndUniverse in sync.js.
225
+ const segments = resolvedProjectId.split("/");
226
+ if (segments.length === 2) {
227
+ const [universeId, projectSlug] = segments;
228
+ scenesBoundaryRoot = path.join(mcpSyncDirAbs, "universes");
229
+ scenesDir = path.resolve(scenesBoundaryRoot, universeId, projectSlug, "scenes");
230
+ } else {
231
+ scenesBoundaryRoot = path.join(mcpSyncDirAbs, "projects");
232
+ scenesDir = path.resolve(scenesBoundaryRoot, resolvedProjectId, "scenes");
233
+ }
166
234
  }
167
235
 
168
236
  const relFromBoundary = path.relative(scenesBoundaryRoot, scenesDir);
package/index.js CHANGED
@@ -7,7 +7,7 @@ import matter from "gray-matter";
7
7
  import yaml from "js-yaml";
8
8
  import { z } from "zod";
9
9
  import { openDb } from "./db.js";
10
- import { syncAll, isSyncDirWritable, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
10
+ import { syncAll, isSyncDirWritable, getSyncOwnershipDiagnostics, writeMeta, readMeta, indexSceneFile, normalizeSceneMetaForPath, sidecarPath } from "./sync.js";
11
11
  import { isGitAvailable, isGitRepository, initGitRepository, createSnapshot, listSnapshots, getSceneProseAtCommit } from "./git.js";
12
12
  import { renderCharacterArcTemplate, renderCharacterSheetTemplate, renderPlaceSheetTemplate, slugifyEntityName } from "./world-entity-templates.js";
13
13
  import { importScrivenerSync, validateProjectId } from "./importer.js";
@@ -220,6 +220,7 @@ process.stderr.write(`[mcp-writing] DB path: ${DB_PATH_DISPLAY}\n`);
220
220
 
221
221
  // Check sync dir writability once at startup (needed for Phase 2 sidecar writes)
222
222
  const SYNC_DIR_WRITABLE = isSyncDirWritable(SYNC_DIR);
223
+ const SYNC_OWNERSHIP_DIAGNOSTICS = getSyncOwnershipDiagnostics(SYNC_DIR);
223
224
  if (!SYNC_DIR_WRITABLE) {
224
225
  process.stderr.write(`[mcp-writing] WARNING: sync dir is not writable — sidecar auto-migration and metadata write-back will be unavailable\n`);
225
226
  }
@@ -263,6 +264,24 @@ function getRuntimeDiagnostics() {
263
264
  recommendations.push("If running in Docker/OpenClaw, verify volume ownership and permissions for the container user.");
264
265
  }
265
266
 
267
+ if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths > 0) {
268
+ warnings.push(
269
+ `OWNERSHIP_MISMATCH: ${SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths} sampled path(s) are not owned by runtime UID ${SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid}.`
270
+ );
271
+ recommendations.push(
272
+ `Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${SYNC_DIR_ABS}"`
273
+ );
274
+ recommendations.push(
275
+ "For Docker, run container as host user (compose: user: \"${UID:-1000}:${GID:-1000}\"). Optionally set UID/GID explicitly in a .env file."
276
+ );
277
+ }
278
+
279
+ if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths > 0) {
280
+ warnings.push(
281
+ `ROOT_OWNED_PATHS: ${SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
282
+ );
283
+ }
284
+
266
285
  if (!GIT_AVAILABLE) {
267
286
  warnings.push("GIT_NOT_FOUND: git is not available on PATH; snapshot/edit tools are unavailable.");
268
287
  recommendations.push("Install git in the runtime image/environment.");
@@ -396,13 +415,14 @@ function createMcpServer() {
396
415
  // ---- get_runtime_config --------------------------------------------------
397
416
  s.tool(
398
417
  "get_runtime_config",
399
- "Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, and git availability). Use this to verify which manuscript location is currently connected.",
418
+ "Show the active runtime paths and capabilities for this server instance (sync dir, database path, writability, permission diagnostics, and git availability). Use this to verify which manuscript location is currently connected.",
400
419
  {},
401
420
  async () => {
402
421
  return jsonResponse({
403
422
  sync_dir: SYNC_DIR_ABS,
404
423
  db_path: DB_PATH_DISPLAY,
405
424
  sync_dir_writable: SYNC_DIR_WRITABLE,
425
+ permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
406
426
  git_available: GIT_AVAILABLE,
407
427
  git_enabled: GIT_ENABLED,
408
428
  http_port: HTTP_PORT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
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",
package/sync.js CHANGED
@@ -53,6 +53,11 @@ export function walkSidecars(dir, fileList = []) {
53
53
  return fileList;
54
54
  }
55
55
 
56
+ function isNestedMirrorPath(syncDir, filePath) {
57
+ const rel = path.relative(syncDir, filePath).split(path.sep).join("/");
58
+ return rel.includes("/scenes/projects/") || rel.includes("/scenes/universes/");
59
+ }
60
+
56
61
  export function sidecarPath(filePath) {
57
62
  return filePath.replace(/\.(md|txt)$/, ".meta.yaml");
58
63
  }
@@ -204,6 +209,94 @@ export function isSyncDirWritable(syncDir) {
204
209
  }
205
210
  }
206
211
 
212
+ function collectOwnershipSample(rootDir, limit = 200) {
213
+ const samples = [];
214
+ const stack = [rootDir];
215
+
216
+ while (stack.length && samples.length < limit) {
217
+ const current = stack.pop();
218
+ samples.push(current);
219
+
220
+ let entries;
221
+ try {
222
+ entries = fs.readdirSync(current, { withFileTypes: true });
223
+ } catch {
224
+ continue;
225
+ }
226
+
227
+ for (const entry of entries) {
228
+ if (samples.length + stack.length >= limit) break;
229
+ if (entry.isSymbolicLink()) continue;
230
+ const full = path.join(current, entry.name);
231
+ if (entry.isDirectory()) {
232
+ stack.push(full);
233
+ } else {
234
+ samples.push(full);
235
+ if (samples.length >= limit) break;
236
+ }
237
+ }
238
+ }
239
+
240
+ return samples;
241
+ }
242
+
243
+ export function getSyncOwnershipDiagnostics(syncDir, { sampleLimit = 200 } = {}) {
244
+ const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
245
+ let syncDirPathExists;
246
+ let syncDirIsDirectory;
247
+ try {
248
+ const stat = fs.statSync(syncDir);
249
+ syncDirPathExists = true;
250
+ syncDirIsDirectory = stat.isDirectory();
251
+ } catch {
252
+ syncDirPathExists = false;
253
+ syncDirIsDirectory = false;
254
+ }
255
+
256
+ const diagnostics = {
257
+ sync_dir: path.resolve(syncDir),
258
+ sync_dir_path_exists: syncDirPathExists,
259
+ sync_dir_is_directory: syncDirIsDirectory,
260
+ // Backwards-compatible: "exists" now means "exists and is a directory".
261
+ sync_dir_exists: syncDirIsDirectory,
262
+ supported: runtimeUid !== null,
263
+ runtime_uid: runtimeUid,
264
+ sampled_paths: 0,
265
+ sample_limit: sampleLimit,
266
+ root_owned_paths: 0,
267
+ non_runtime_owned_paths: 0,
268
+ unreadable_paths: 0,
269
+ root_owned_examples: [],
270
+ non_runtime_owned_examples: [],
271
+ };
272
+
273
+ if (!diagnostics.sync_dir_is_directory || runtimeUid === null) {
274
+ return diagnostics;
275
+ }
276
+
277
+ const sample = collectOwnershipSample(syncDir, sampleLimit);
278
+ diagnostics.sampled_paths = sample.length;
279
+
280
+ for (const filePath of sample) {
281
+ try {
282
+ const stat = fs.statSync(filePath);
283
+ const rel = path.relative(syncDir, filePath) || ".";
284
+ if (stat.uid === 0) {
285
+ diagnostics.root_owned_paths++;
286
+ if (diagnostics.root_owned_examples.length < 5) diagnostics.root_owned_examples.push(rel);
287
+ }
288
+ if (stat.uid !== runtimeUid) {
289
+ diagnostics.non_runtime_owned_paths++;
290
+ if (diagnostics.non_runtime_owned_examples.length < 5) diagnostics.non_runtime_owned_examples.push(rel);
291
+ }
292
+ } catch {
293
+ diagnostics.unreadable_paths++;
294
+ }
295
+ }
296
+
297
+ return diagnostics;
298
+ }
299
+
207
300
  // ---------------------------------------------------------------------------
208
301
  // DB-dependent sync (takes db + syncDir as arguments for testability)
209
302
  // ---------------------------------------------------------------------------
@@ -368,9 +461,18 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
368
461
  const indexedSceneIds = new Set(); // scene_id only — for orphaned sidecar move detection
369
462
  const warnings = [];
370
463
 
464
+ const scanFiles = [];
465
+ for (const file of files) {
466
+ if (isNestedMirrorPath(syncDir, file)) {
467
+ warnings.push(`Ignored nested mirror path: ${path.relative(syncDir, file)}`);
468
+ continue;
469
+ }
470
+ scanFiles.push(file);
471
+ }
472
+
371
473
  // --- Pass 1: world files (characters/places must be indexed before scenes
372
474
  // so that character name → ID resolution in scene_characters works) ---
373
- for (const file of files) {
475
+ for (const file of scanFiles) {
374
476
  if (!isWorldFile(syncDir, file)) continue;
375
477
  try {
376
478
  const { meta } = readMeta(file, syncDir, { writable });
@@ -386,7 +488,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
386
488
  }
387
489
 
388
490
  // --- Pass 2: scene files ---
389
- for (const file of files) {
491
+ for (const file of scanFiles) {
390
492
  if (isWorldFile(syncDir, file)) continue;
391
493
  try {
392
494
  const { meta, sourceMeta, sidecarGenerated, derived, mismatches } = readMeta(file, syncDir, { writable });
@@ -431,7 +533,7 @@ export function syncAll(db, syncDir, { quiet = false, writable = false } = {}) {
431
533
  }
432
534
 
433
535
  // --- Orphaned sidecar detection ---
434
- const sidecars = walkSidecars(syncDir);
536
+ const sidecars = walkSidecars(syncDir).filter(sidecar => !isNestedMirrorPath(syncDir, sidecar));
435
537
  for (const sidecar of sidecars) {
436
538
  const prose = sidecar.replace(/\.meta\.yaml$/, ".md");
437
539
  const proseTxt = sidecar.replace(/\.meta\.yaml$/, ".txt");