@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 +10 -0
- package/README.md +21 -0
- package/index.js +22 -2
- package/package.json +1 -1
- package/sync.js +105 -3
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
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
|
|
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
|
|
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");
|