@hanna84/mcp-writing 1.5.2 → 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,21 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ #### [v1.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
+
7
13
  #### [v1.5.2](https://github.com/hannasdev/mcp-writing.git
8
14
  /compare/v1.5.1...v1.5.2)
9
15
 
16
+ > 19 April 2026
17
+
10
18
  - fix: avoid nested scenes/projects paths on import [`#44`](https://github.com/hannasdev/mcp-writing.git
11
19
  /pull/44)
20
+ - Release 1.5.2 [`47f7c6c`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/47f7c6c9ddbc4244b8a62b1dc37c86eb914aafbc)
12
22
 
13
23
  #### [v1.5.1](https://github.com/hannasdev/mcp-writing.git
14
24
  /compare/v1.5.0...v1.5.1)
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/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.2",
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");