@hanna84/mcp-writing 1.6.2 → 1.7.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.7.0](https://github.com/hannasdev/mcp-writing.git
8
+ /compare/v1.6.3...v1.7.0)
9
+
10
+ - feat: configurable OpenClaw runtime identity and ownership guardrails [`#49`](https://github.com/hannasdev/mcp-writing.git
11
+ /pull/49)
12
+
13
+ #### [v1.6.3](https://github.com/hannasdev/mcp-writing.git
14
+ /compare/v1.6.2...v1.6.3)
15
+
16
+ > 19 April 2026
17
+
18
+ - docs: sync PRD with current app behavior [`#48`](https://github.com/hannasdev/mcp-writing.git
19
+ /pull/48)
20
+ - Release 1.6.3 [`53add10`](https://github.com/hannasdev/mcp-writing.git
21
+ /commit/53add102551c586c81aaef249509eec2c5487f96)
22
+
7
23
  #### [v1.6.2](https://github.com/hannasdev/mcp-writing.git
8
24
  /compare/v1.6.1...v1.6.2)
9
25
 
26
+ > 19 April 2026
27
+
10
28
  - docs: correct README license footer to AGPL-3.0-only [`#47`](https://github.com/hannasdev/mcp-writing.git
11
29
  /pull/47)
30
+ - Release 1.6.2 [`27e92ab`](https://github.com/hannasdev/mcp-writing.git
31
+ /commit/27e92ab8718b4c5736cf901c479dc91ea8259be0)
12
32
 
13
33
  #### [v1.6.1](https://github.com/hannasdev/mcp-writing.git
14
34
  /compare/v1.6.0...v1.6.1)
package/README.md CHANGED
@@ -339,13 +339,16 @@ Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `se
339
339
  # docker-compose.yml snippet
340
340
  writing-mcp:
341
341
  build: .
342
- user: "${UID:-1000}:${GID:-1000}"
342
+ user: "${OPENCLAW_UID:-1000}:${OPENCLAW_GID:-1000}"
343
343
  environment:
344
344
  WRITING_SYNC_DIR: /sync
345
345
  DB_PATH: /data/writing.db
346
346
  HTTP_PORT: "3000"
347
+ OWNERSHIP_GUARD_MODE: "${OWNERSHIP_GUARD_MODE:-warn}"
348
+ GIT_SSH_COMMAND: "ssh -i /ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/ssh/known_hosts"
347
349
  volumes:
348
- - /path/to/sync-dir:/sync
350
+ - ${OPENCLAW_WORKSPACE_DIR:?run scripts/setup-openclaw-env.sh first}/sync:/sync
351
+ - ${OPENCLAW_SSH_DIR:?run scripts/setup-openclaw-env.sh first}:/ssh:ro
349
352
  - writing-mcp-data:/data
350
353
  healthcheck:
351
354
  test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
@@ -357,7 +360,15 @@ volumes:
357
360
  writing-mcp-data:
358
361
  ```
359
362
 
360
- If you want explicit host mapping, set `UID` and `GID` in your shell or a `.env` file next to `docker-compose.yml`.
363
+ Recommended: start from `docker-compose.example.yml` and generate `.env` with machine-specific values:
364
+
365
+ ```sh
366
+ sh scripts/setup-openclaw-env.sh
367
+ ```
368
+
369
+ That script writes `OPENCLAW_UID`, `OPENCLAW_GID`, `OPENCLAW_WORKSPACE_DIR`, and `OPENCLAW_SSH_DIR` to `.env`.
370
+ Running Compose without these values is unsupported and may create invalid mount definitions.
371
+ It also normalizes `OWNERSHIP_GUARD_MODE` to `warn` or `fail` and preserves an existing valid value when rerun.
361
372
 
362
373
  Then register in your OpenClaw config:
363
374
 
@@ -380,8 +391,17 @@ When `mcp-writing` runs behind OpenClaw (or any Docker MCP gateway), these detai
380
391
 
381
392
  - Set `WRITING_SYNC_DIR=/sync`
382
393
  - Set `DB_PATH=/data/writing.db`
394
+ - Set `OWNERSHIP_GUARD_MODE=warn` (or `fail` to block startup on ownership drift)
383
395
  - Mount your manuscript sync repo to `/sync`
384
396
  - Mount a persistent path for SQLite data at `/data`
397
+ - Mount SSH materials read-only at `/ssh` and use `GIT_SSH_COMMAND` with `/ssh` paths
398
+
399
+ Debug/test-only runtime override knobs:
400
+
401
+ - `RUNTIME_UID_OVERRIDE` — test helper to simulate runtime UID during ownership diagnostics
402
+ - `ALLOW_RUNTIME_UID_OVERRIDE=1` — explicitly enables the override outside `NODE_ENV=test`
403
+
404
+ Do not set these in normal production or desktop deployments.
385
405
 
386
406
  If `/sync` contains raw Scrivener external-sync output, run the importer once before normal `sync` usage:
387
407
 
@@ -414,7 +434,7 @@ For private remotes, mount SSH materials read-only and enforce strict host check
414
434
  Example:
415
435
 
416
436
  ```sh
417
- export GIT_SSH_COMMAND="ssh -i /root/.ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/root/.ssh/known_hosts"
437
+ export GIT_SSH_COMMAND="ssh -i /ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/ssh/known_hosts"
418
438
  ```
419
439
 
420
440
  #### Separate auth and signing keys
@@ -603,6 +623,22 @@ git config --system --add safe.directory /sync
603
623
  4. Verify SSH key has write access to the remote and `known_hosts` is mounted.
604
624
  5. Prefer branch-per-change workflow (`bot/*` or `edda/*`) if `main` is protected.
605
625
 
626
+ ### "Blocked: file is root-owned" (EACCES / ownership drift)
627
+
628
+ The runtime user can read but cannot overwrite prose files.
629
+
630
+ Fix:
631
+
632
+ 1. Repair host ownership once:
633
+
634
+ ```sh
635
+ sudo chown -R "$(id -u):$(id -g)" /path/to/sync-dir
636
+ ```
637
+
638
+ 2. Ensure container user mapping is set from `.env` (`OPENCLAW_UID` / `OPENCLAW_GID`).
639
+ 3. Optionally set `OWNERSHIP_GUARD_MODE=fail` to catch mismatches at startup.
640
+ 4. Re-check `get_runtime_config` and confirm ownership warnings are gone.
641
+
606
642
  ### Tests fail after updating Node.js
607
643
 
608
644
  Local install state may be stale after the Node.js change.
@@ -624,6 +660,7 @@ npm test
624
660
  | `HTTP_PORT` | `3000` | Port for the MCP SSE endpoint |
625
661
  | `MAX_CHAPTER_SCENES` | `10` | Maximum scenes returned by `get_chapter_prose` |
626
662
  | `DEFAULT_METADATA_PAGE_SIZE` | `20` | Default page size for paginated tools |
663
+ | `OWNERSHIP_GUARD_MODE` | `warn` | Startup ownership policy: `warn` logs drift, `fail` exits when sampled files are not owned by runtime user |
627
664
 
628
665
  ## License
629
666
 
package/index.js CHANGED
@@ -19,6 +19,11 @@ const DB_PATH_DISPLAY = DB_PATH === ":memory:" ? DB_PATH : path.resolve(DB_PATH)
19
19
  const HTTP_PORT = parseInt(process.env.HTTP_PORT ?? "3000", 10);
20
20
  const MAX_CHAPTER_SCENES = parseInt(process.env.MAX_CHAPTER_SCENES ?? "10", 10);
21
21
  const DEFAULT_METADATA_PAGE_SIZE = parseInt(process.env.DEFAULT_METADATA_PAGE_SIZE ?? "20", 10);
22
+ const OWNERSHIP_GUARD_MODE_RAW = (process.env.OWNERSHIP_GUARD_MODE ?? "warn").trim().toLowerCase();
23
+ const OWNERSHIP_GUARD_MODE = OWNERSHIP_GUARD_MODE_RAW === "fail" || OWNERSHIP_GUARD_MODE_RAW === "warn"
24
+ ? OWNERSHIP_GUARD_MODE_RAW
25
+ : "warn";
26
+ const OWNERSHIP_GUARD_MODE_RAW_DISPLAY = JSON.stringify(OWNERSHIP_GUARD_MODE_RAW);
22
27
 
23
28
  function paginateRows(rows, { page, pageSize, forcePagination = false }) {
24
29
  const totalCount = rows.length;
@@ -302,6 +307,23 @@ function getRuntimeDiagnostics() {
302
307
  const warnings = [];
303
308
  const recommendations = [];
304
309
 
310
+ if (OWNERSHIP_GUARD_MODE_RAW !== OWNERSHIP_GUARD_MODE) {
311
+ warnings.push(
312
+ `OWNERSHIP_GUARD_MODE_INVALID: Unsupported OWNERSHIP_GUARD_MODE=${OWNERSHIP_GUARD_MODE_RAW_DISPLAY}. Falling back to 'warn'.`
313
+ );
314
+ recommendations.push("Set OWNERSHIP_GUARD_MODE to either 'warn' or 'fail'.");
315
+ }
316
+
317
+ if (SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid_override_ignored) {
318
+ warnings.push("RUNTIME_UID_OVERRIDE_IGNORED: RUNTIME_UID_OVERRIDE is ignored unless NODE_ENV=test or ALLOW_RUNTIME_UID_OVERRIDE=1.");
319
+ recommendations.push("Avoid RUNTIME_UID_OVERRIDE in production runtime environments.");
320
+ }
321
+
322
+ if (SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid_override_invalid) {
323
+ warnings.push("RUNTIME_UID_OVERRIDE_INVALID: RUNTIME_UID_OVERRIDE must be a non-negative integer when enabled.");
324
+ recommendations.push("Set RUNTIME_UID_OVERRIDE to a non-negative integer, or unset it.");
325
+ }
326
+
305
327
  if (!SYNC_DIR_WRITABLE) {
306
328
  warnings.push("SYNC_DIR_READ_ONLY: sync dir is read-only; metadata write-back and prose editing tools are unavailable.");
307
329
  recommendations.push("Mount WRITING_SYNC_DIR with write access (avoid read-only mounts like ':ro').");
@@ -316,10 +338,17 @@ function getRuntimeDiagnostics() {
316
338
  `Repair ownership once on host: sudo chown -R "$(id -u):$(id -g)" "${SYNC_DIR_ABS}"`
317
339
  );
318
340
  recommendations.push(
319
- "For Docker, run container as host user (compose: user: \"${UID:-1000}:${GID:-1000}\"). Optionally set UID/GID explicitly in a .env file."
341
+ "For Docker/OpenClaw, run container as host user (compose: user: \"${OPENCLAW_UID:-1000}:${OPENCLAW_GID:-1000}\")."
320
342
  );
321
343
  }
322
344
 
345
+ if (OWNERSHIP_GUARD_MODE === "fail" && SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid === 0) {
346
+ warnings.push(
347
+ "OWNERSHIP_GUARD_SKIPPED_FOR_ROOT: OWNERSHIP_GUARD_MODE=fail is skipped because runtime UID is 0 (root)."
348
+ );
349
+ recommendations.push("Prefer running as a non-root host-mapped UID/GID to make ownership guard checks meaningful.");
350
+ }
351
+
323
352
  if (SYNC_OWNERSHIP_DIAGNOSTICS.supported && SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths > 0) {
324
353
  warnings.push(
325
354
  `ROOT_OWNED_PATHS: ${SYNC_OWNERSHIP_DIAGNOSTICS.root_owned_paths} sampled path(s) are owned by UID 0 (root).`
@@ -353,6 +382,20 @@ if (RUNTIME_DIAGNOSTICS.warnings.length) {
353
382
  }
354
383
  }
355
384
 
385
+ const SHOULD_ENFORCE_OWNERSHIP_FAIL_GUARD = OWNERSHIP_GUARD_MODE === "fail"
386
+ && SYNC_OWNERSHIP_DIAGNOSTICS.supported
387
+ && SYNC_OWNERSHIP_DIAGNOSTICS.runtime_uid !== 0;
388
+
389
+ if (SHOULD_ENFORCE_OWNERSHIP_FAIL_GUARD && SYNC_OWNERSHIP_DIAGNOSTICS.non_runtime_owned_paths > 0) {
390
+ process.stderr.write(
391
+ `[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`
392
+ );
393
+ process.stderr.write(
394
+ `[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`
395
+ );
396
+ process.exit(1);
397
+ }
398
+
356
399
  // Run sync on startup
357
400
  syncAll(db, SYNC_DIR, { writable: SYNC_DIR_WRITABLE });
358
401
 
@@ -466,6 +509,7 @@ function createMcpServer() {
466
509
  sync_dir: SYNC_DIR_ABS,
467
510
  db_path: DB_PATH_DISPLAY,
468
511
  sync_dir_writable: SYNC_DIR_WRITABLE,
512
+ ownership_guard_mode: OWNERSHIP_GUARD_MODE,
469
513
  permission_diagnostics: SYNC_OWNERSHIP_DIAGNOSTICS,
470
514
  git_available: GIT_AVAILABLE,
471
515
  git_enabled: GIT_ENABLED,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanna84/mcp-writing",
3
- "version": "1.6.2",
3
+ "version": "1.7.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",
@@ -21,6 +21,7 @@
21
21
  "scripts": {
22
22
  "start": "node --experimental-sqlite index.js",
23
23
  "new:entity": "node scripts/new-world-entity.js",
24
+ "setup:openclaw-env": "sh scripts/setup-openclaw-env.sh",
24
25
  "release": "release-it",
25
26
  "lint": "eslint index.js importer.js db.js sync.js metadata-lint.js scripts/",
26
27
  "lint:metadata": "node scripts/lint-metadata.mjs",
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ ENV_FILE="${1:-.env}"
5
+ OPENCLAW_UID_VALUE="$(id -u)"
6
+ OPENCLAW_GID_VALUE="$(id -g)"
7
+ OPENCLAW_WORKSPACE_DIR_VALUE="${OPENCLAW_WORKSPACE_DIR:-$(pwd)}"
8
+ OPENCLAW_SSH_DIR_VALUE="${OPENCLAW_SSH_DIR:-${HOME:-$(pwd)}/.ssh}"
9
+ OWNERSHIP_GUARD_MODE_VALUE="${2:-${OWNERSHIP_GUARD_MODE:-}}"
10
+
11
+ quote_env_value() {
12
+ printf '"%s"' "$(printf '%s' "$1" | sed 's/["\\]/\\&/g')"
13
+ }
14
+
15
+ if [ -z "$OWNERSHIP_GUARD_MODE_VALUE" ] && [ -f "$ENV_FILE" ]; then
16
+ EXISTING_GUARD_MODE="$(grep -E '^OWNERSHIP_GUARD_MODE=' "$ENV_FILE" | tail -n1 | cut -d= -f2- || true)"
17
+ OWNERSHIP_GUARD_MODE_VALUE="$EXISTING_GUARD_MODE"
18
+ fi
19
+
20
+ if [ -z "$OWNERSHIP_GUARD_MODE_VALUE" ]; then
21
+ OWNERSHIP_GUARD_MODE_VALUE="warn"
22
+ fi
23
+
24
+ NORMALIZED_GUARD_MODE="$(printf '%s' "$OWNERSHIP_GUARD_MODE_VALUE" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
25
+ if [ "$NORMALIZED_GUARD_MODE" != "warn" ] && [ "$NORMALIZED_GUARD_MODE" != "fail" ]; then
26
+ printf 'Warning: unsupported OWNERSHIP_GUARD_MODE=%s, defaulting to "warn".\n' "$OWNERSHIP_GUARD_MODE_VALUE" >&2
27
+ OWNERSHIP_GUARD_MODE_VALUE="warn"
28
+ else
29
+ OWNERSHIP_GUARD_MODE_VALUE="$NORMALIZED_GUARD_MODE"
30
+ fi
31
+
32
+ if TMP_FILE="$(mktemp -t openclaw-env.XXXXXX 2>/dev/null)"; then
33
+ :
34
+ else
35
+ TMP_FILE="$(mktemp "${TMPDIR:-/tmp}/openclaw-env.XXXXXX")"
36
+ fi
37
+ trap 'rm -f "$TMP_FILE"' EXIT
38
+
39
+ if [ -f "$ENV_FILE" ]; then
40
+ awk '
41
+ !/^OPENCLAW_UID=/ &&
42
+ !/^OPENCLAW_GID=/ &&
43
+ !/^OPENCLAW_WORKSPACE_DIR=/ &&
44
+ !/^OPENCLAW_SSH_DIR=/ &&
45
+ !/^OWNERSHIP_GUARD_MODE=/
46
+ ' "$ENV_FILE" > "$TMP_FILE"
47
+ fi
48
+
49
+ {
50
+ cat "$TMP_FILE"
51
+ if [ -s "$TMP_FILE" ]; then
52
+ printf "\n"
53
+ fi
54
+ printf "OPENCLAW_UID=%s\n" "$OPENCLAW_UID_VALUE"
55
+ printf "OPENCLAW_GID=%s\n" "$OPENCLAW_GID_VALUE"
56
+ printf "OPENCLAW_WORKSPACE_DIR=%s\n" "$(quote_env_value "$OPENCLAW_WORKSPACE_DIR_VALUE")"
57
+ printf "OPENCLAW_SSH_DIR=%s\n" "$(quote_env_value "$OPENCLAW_SSH_DIR_VALUE")"
58
+ printf "OWNERSHIP_GUARD_MODE=%s\n" "$OWNERSHIP_GUARD_MODE_VALUE"
59
+ } > "$ENV_FILE"
60
+
61
+ printf "Wrote %s with OPENCLAW runtime variables.\n" "$ENV_FILE"
62
+ printf "UID:GID=%s:%s\n" "$OPENCLAW_UID_VALUE" "$OPENCLAW_GID_VALUE"
63
+ printf "WORKSPACE=%s\n" "$OPENCLAW_WORKSPACE_DIR_VALUE"
64
+ printf "SSH_DIR=%s\n" "$OPENCLAW_SSH_DIR_VALUE"
package/sync.js CHANGED
@@ -241,7 +241,26 @@ function collectOwnershipSample(rootDir, limit = 200) {
241
241
  }
242
242
 
243
243
  export function getSyncOwnershipDiagnostics(syncDir, { sampleLimit = 200 } = {}) {
244
- const runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
244
+ let runtimeUid = typeof process.getuid === "function" ? process.getuid() : null;
245
+ const runtimeUidOverrideRaw = process.env.RUNTIME_UID_OVERRIDE;
246
+ const runtimeUidOverrideAllowed = process.env.NODE_ENV === "test" || process.env.ALLOW_RUNTIME_UID_OVERRIDE === "1";
247
+ let runtimeUidOverrideApplied = false;
248
+ let runtimeUidOverrideIgnored = false;
249
+ let runtimeUidOverrideInvalid = false;
250
+
251
+ if (runtimeUidOverrideRaw !== undefined) {
252
+ if (!runtimeUidOverrideAllowed) {
253
+ runtimeUidOverrideIgnored = true;
254
+ } else {
255
+ const parsed = Number.parseInt(runtimeUidOverrideRaw, 10);
256
+ if (Number.isInteger(parsed) && parsed >= 0) {
257
+ runtimeUid = parsed;
258
+ runtimeUidOverrideApplied = true;
259
+ } else {
260
+ runtimeUidOverrideInvalid = true;
261
+ }
262
+ }
263
+ }
245
264
  let syncDirPathExists;
246
265
  let syncDirIsDirectory;
247
266
  try {
@@ -261,6 +280,10 @@ export function getSyncOwnershipDiagnostics(syncDir, { sampleLimit = 200 } = {})
261
280
  sync_dir_exists: syncDirIsDirectory,
262
281
  supported: runtimeUid !== null,
263
282
  runtime_uid: runtimeUid,
283
+ runtime_uid_override_requested: runtimeUidOverrideRaw !== undefined,
284
+ runtime_uid_override_applied: runtimeUidOverrideApplied,
285
+ runtime_uid_override_ignored: runtimeUidOverrideIgnored,
286
+ runtime_uid_override_invalid: runtimeUidOverrideInvalid,
264
287
  sampled_paths: 0,
265
288
  sample_limit: sampleLimit,
266
289
  root_owned_paths: 0,