@hanna84/mcp-writing 1.0.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/README.md +193 -0
- package/db.js +127 -0
- package/index.js +892 -0
- package/metadata-lint.js +332 -0
- package/package.json +42 -0
- package/scripts/import.js +360 -0
- package/scripts/lint-metadata.mjs +28 -0
- package/scripts/merge-scrivx.js +319 -0
- package/scripts/split-versions.js +26 -0
- package/sync.js +431 -0
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# mcp-writing
|
|
2
|
+
|
|
3
|
+
An MCP service for AI-assisted reasoning and editing on long-form fiction projects.
|
|
4
|
+
|
|
5
|
+
Designed to work with [OpenClaw](https://github.com/openclaw/openclaw) but compatible with any MCP-capable AI gateway.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
Instead of feeding an entire manuscript to an AI and hoping it fits in the context window, `mcp-writing` builds a structured index from your scene files. The AI queries that index first — finding relevant characters, beats, and loglines — then loads only the specific prose it needs.
|
|
10
|
+
|
|
11
|
+
**Phase 1:** Read-only analysis. Ask questions about your project.
|
|
12
|
+
**Phase 2 (current):** Metadata write-back. Answers stay accurate as the manuscript evolves.
|
|
13
|
+
**Phase 3:** AI-assisted prose editing with confirmation and version history.
|
|
14
|
+
|
|
15
|
+
## Quick start with Scrivener
|
|
16
|
+
|
|
17
|
+
If you write in [Scrivener](https://www.literatureandlatte.com/scrivener), you can seed `mcp-writing` from a Scrivener external-sync export in two steps.
|
|
18
|
+
|
|
19
|
+
### 1. Export from Scrivener
|
|
20
|
+
|
|
21
|
+
In Scrivener: **File → Sync → With External Folder**. Set the format to **plain text** (`.txt`) and pick an output folder, for example `~/my-novel-txt/`. Your `Draft/` folder and `Notes/` folder will be exported as numbered `.txt` files.
|
|
22
|
+
|
|
23
|
+
### 2. Import into mcp-writing
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
node scripts/import.js ~/my-novel-txt /path/to/sync-dir --project my-novel
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The importer:
|
|
30
|
+
- Converts `Draft/` files to scene sidecars (`.meta.yaml`) with auto-generated `scene_id`, `title`, `part`, `chapter`, and `save_the_cat_beat` fields derived from the filename/structure.
|
|
31
|
+
- Routes `Notes/` files into `world/characters/` or `world/places/` based on section grouping.
|
|
32
|
+
- Skips beat-marker files (`-Setup-`, `-Catalyst-`, etc.), chapter-intro files, epigraphs, and trashed files.
|
|
33
|
+
|
|
34
|
+
> **Note:** The importer writes a `group` key to character sidecar files so characters stay organized by their Notes section (e.g. "Main Characters", "Mira's team"). This is preserved through lint and sync.
|
|
35
|
+
|
|
36
|
+
### 3. Start the server
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
WRITING_SYNC_DIR=/path/to/sync-dir DB_PATH=./writing.db npm start
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then call the `sync` tool once to index everything.
|
|
43
|
+
|
|
44
|
+
### 4. Lint your metadata (optional)
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
node scripts/lint-metadata.mjs --sync-dir /path/to/sync-dir
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Exits non-zero if any errors are found. Warnings (e.g. `UNKNOWN_KEY`) are informational only.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Native sync format
|
|
55
|
+
|
|
56
|
+
For projects not starting from a Scrivener export, place plain `.md` files in the sync folder directly. Metadata lives in a YAML frontmatter block.
|
|
57
|
+
|
|
58
|
+
### Scene file example
|
|
59
|
+
|
|
60
|
+
```markdown
|
|
61
|
+
---
|
|
62
|
+
scene_id: p1-ch2-sc3
|
|
63
|
+
title: The Arrival
|
|
64
|
+
part: 1
|
|
65
|
+
chapter: 2
|
|
66
|
+
characters: [elena, marcus]
|
|
67
|
+
places: [harbor-district]
|
|
68
|
+
logline: Elena arrives at the harbor and meets Marcus for the first time.
|
|
69
|
+
save_the_cat_beat: Setup
|
|
70
|
+
pov: elena
|
|
71
|
+
timeline_position: 4
|
|
72
|
+
story_time: "Day 1, morning"
|
|
73
|
+
tags: [first-meeting, tension]
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
Prose starts here...
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Alternatively, metadata can live in a sidecar file named `<scene-file>.meta.yaml` alongside the prose file — useful for keeping the prose file clean.
|
|
80
|
+
|
|
81
|
+
### Project structure
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
/sync-root/
|
|
85
|
+
/universes/
|
|
86
|
+
/my-series/
|
|
87
|
+
/world/
|
|
88
|
+
characters/elena.md
|
|
89
|
+
places/harbor-district.md
|
|
90
|
+
/book-1/
|
|
91
|
+
/part-1/chapter-1/scene-001.md
|
|
92
|
+
/projects/
|
|
93
|
+
/standalone-novel/
|
|
94
|
+
/world/
|
|
95
|
+
characters/
|
|
96
|
+
places/
|
|
97
|
+
/part-1/chapter-1/scene-001.md
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Universe-level characters and places are shared across all books in that universe. Standalone projects are fully isolated.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Available tools
|
|
105
|
+
|
|
106
|
+
| Tool | Description |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `sync` | Re-scan the sync folder and update the index |
|
|
109
|
+
| `find_scenes` | Filter scenes by character, beat, tag, part, chapter, or POV |
|
|
110
|
+
| `get_scene_prose` | Load the full prose for a specific scene |
|
|
111
|
+
| `get_chapter_prose` | Load all prose for a chapter |
|
|
112
|
+
| `get_arc` | Ordered scene metadata for all scenes involving a character |
|
|
113
|
+
| `list_characters` | All characters, optionally filtered by project or universe |
|
|
114
|
+
| `get_character_sheet` | Full character metadata, traits, and notes |
|
|
115
|
+
| `list_places` | All places |
|
|
116
|
+
| `search_metadata` | Full-text search across scene titles and loglines |
|
|
117
|
+
| `list_threads` | All subplot threads for a project |
|
|
118
|
+
| `get_thread_arc` | Scenes belonging to a thread, with per-thread beat |
|
|
119
|
+
| `upsert_thread_link` | Create/update a thread and link it to a scene |
|
|
120
|
+
| `enrich_scene` | Re-derive lightweight metadata from current prose and clear `metadata_stale` |
|
|
121
|
+
| `update_scene_metadata` | Write metadata fields back to a scene sidecar |
|
|
122
|
+
| `update_character_sheet` | Write fields back to a character sidecar |
|
|
123
|
+
| `flag_scene` | Mark a scene with a flag for AI follow-up |
|
|
124
|
+
|
|
125
|
+
Paginated tools (`find_scenes`, `get_arc`, `list_threads`, `get_thread_arc`, `search_metadata`) accept `page` and `page_size` arguments and return `total_count` / `total_pages` in the response envelope.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Running with Docker
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
# docker-compose.yml snippet
|
|
133
|
+
writing-mcp:
|
|
134
|
+
build: .
|
|
135
|
+
environment:
|
|
136
|
+
WRITING_SYNC_DIR: /sync
|
|
137
|
+
DB_PATH: /data/writing.db
|
|
138
|
+
HTTP_PORT: "3000"
|
|
139
|
+
volumes:
|
|
140
|
+
- /path/to/sync-dir:/sync
|
|
141
|
+
- writing-mcp-data:/data
|
|
142
|
+
healthcheck:
|
|
143
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
144
|
+
interval: 30s
|
|
145
|
+
timeout: 5s
|
|
146
|
+
retries: 5
|
|
147
|
+
|
|
148
|
+
volumes:
|
|
149
|
+
writing-mcp-data:
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Then register in your OpenClaw config:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
"mcp": {
|
|
156
|
+
"servers": {
|
|
157
|
+
"writing": { "url": "http://writing-mcp:3000/sse" }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Running locally
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
npm install
|
|
166
|
+
WRITING_SYNC_DIR=./my-manuscript DB_PATH=./writing.db npm start
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
npm install
|
|
173
|
+
npm test # unit + integration tests
|
|
174
|
+
npm run test:unit # unit tests only (no server required)
|
|
175
|
+
npm run lint:metadata # lint metadata in WRITING_SYNC_DIR or ./sync
|
|
176
|
+
npm run lint:metadata:test # lint fixture metadata in ./test-sync
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Unit tests use an in-memory SQLite database and temporary directories — no server needed. Integration tests spawn a real server against `test-sync/` on port 3099 and verify all MCP tools end-to-end.
|
|
180
|
+
|
|
181
|
+
## Environment variables
|
|
182
|
+
|
|
183
|
+
| Variable | Default | Description |
|
|
184
|
+
| --- | --- | --- |
|
|
185
|
+
| `WRITING_SYNC_DIR` | `./sync` | Path to the sync folder |
|
|
186
|
+
| `DB_PATH` | `./writing.db` | Path to the SQLite index database |
|
|
187
|
+
| `HTTP_PORT` | `3000` | Port for the MCP SSE endpoint |
|
|
188
|
+
| `MAX_CHAPTER_SCENES` | `10` | Maximum scenes returned by `get_chapter_prose` |
|
|
189
|
+
| `DEFAULT_METADATA_PAGE_SIZE` | `20` | Default page size for paginated tools |
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
package/db.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
export const SCHEMA = `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS universes (
|
|
5
|
+
universe_id TEXT PRIMARY KEY,
|
|
6
|
+
name TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
10
|
+
project_id TEXT PRIMARY KEY,
|
|
11
|
+
universe_id TEXT REFERENCES universes(universe_id),
|
|
12
|
+
name TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS scenes (
|
|
16
|
+
scene_id TEXT NOT NULL,
|
|
17
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
|
18
|
+
title TEXT,
|
|
19
|
+
part INTEGER,
|
|
20
|
+
chapter INTEGER,
|
|
21
|
+
pov TEXT,
|
|
22
|
+
logline TEXT,
|
|
23
|
+
scene_change TEXT,
|
|
24
|
+
causality INTEGER,
|
|
25
|
+
stakes INTEGER,
|
|
26
|
+
scene_functions TEXT,
|
|
27
|
+
save_the_cat_beat TEXT,
|
|
28
|
+
timeline_position INTEGER,
|
|
29
|
+
story_time TEXT,
|
|
30
|
+
word_count INTEGER,
|
|
31
|
+
file_path TEXT NOT NULL,
|
|
32
|
+
prose_checksum TEXT,
|
|
33
|
+
metadata_stale INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
updated_at TEXT NOT NULL,
|
|
35
|
+
PRIMARY KEY (scene_id, project_id)
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS scene_characters (
|
|
39
|
+
scene_id TEXT NOT NULL,
|
|
40
|
+
character_id TEXT NOT NULL,
|
|
41
|
+
PRIMARY KEY (scene_id, character_id)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS scene_places (
|
|
45
|
+
scene_id TEXT NOT NULL,
|
|
46
|
+
place_id TEXT NOT NULL,
|
|
47
|
+
PRIMARY KEY (scene_id, place_id)
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS scene_tags (
|
|
51
|
+
scene_id TEXT NOT NULL,
|
|
52
|
+
tag TEXT NOT NULL,
|
|
53
|
+
PRIMARY KEY (scene_id, tag)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS scene_threads (
|
|
57
|
+
scene_id TEXT NOT NULL,
|
|
58
|
+
thread_id TEXT NOT NULL,
|
|
59
|
+
beat TEXT,
|
|
60
|
+
PRIMARY KEY (scene_id, thread_id)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS characters (
|
|
64
|
+
character_id TEXT NOT NULL PRIMARY KEY,
|
|
65
|
+
project_id TEXT,
|
|
66
|
+
universe_id TEXT,
|
|
67
|
+
name TEXT NOT NULL,
|
|
68
|
+
role TEXT,
|
|
69
|
+
arc_summary TEXT,
|
|
70
|
+
first_appearance TEXT,
|
|
71
|
+
file_path TEXT
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE TABLE IF NOT EXISTS character_traits (
|
|
75
|
+
character_id TEXT NOT NULL,
|
|
76
|
+
trait TEXT NOT NULL,
|
|
77
|
+
PRIMARY KEY (character_id, trait)
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS character_relationships (
|
|
81
|
+
from_character TEXT NOT NULL,
|
|
82
|
+
to_character TEXT NOT NULL,
|
|
83
|
+
relationship_type TEXT NOT NULL,
|
|
84
|
+
strength TEXT,
|
|
85
|
+
scene_id TEXT,
|
|
86
|
+
note TEXT
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS places (
|
|
90
|
+
place_id TEXT NOT NULL PRIMARY KEY,
|
|
91
|
+
project_id TEXT,
|
|
92
|
+
universe_id TEXT,
|
|
93
|
+
name TEXT NOT NULL,
|
|
94
|
+
file_path TEXT
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
98
|
+
thread_id TEXT NOT NULL PRIMARY KEY,
|
|
99
|
+
project_id TEXT NOT NULL,
|
|
100
|
+
name TEXT NOT NULL,
|
|
101
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
CREATE TABLE IF NOT EXISTS reference_docs (
|
|
105
|
+
doc_id TEXT NOT NULL PRIMARY KEY,
|
|
106
|
+
project_id TEXT,
|
|
107
|
+
universe_id TEXT,
|
|
108
|
+
title TEXT NOT NULL,
|
|
109
|
+
file_path TEXT NOT NULL
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE IF NOT EXISTS reference_doc_tags (
|
|
113
|
+
doc_id TEXT NOT NULL,
|
|
114
|
+
tag TEXT NOT NULL,
|
|
115
|
+
PRIMARY KEY (doc_id, tag)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS scenes_fts USING fts5(
|
|
119
|
+
scene_id, project_id, logline, title
|
|
120
|
+
);
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
export function openDb(dbPath) {
|
|
124
|
+
const db = new DatabaseSync(dbPath);
|
|
125
|
+
db.exec(SCHEMA);
|
|
126
|
+
return db;
|
|
127
|
+
}
|