# StoryFolder MCP server

Issue #99. A local [MCP](https://modelcontextprotocol.io) server that lets an LLM
chat client read and edit the user's StoryFolder library by talking to the
running desktop app.

Phase 1 is local-only: **Claude Desktop** (one-click `.mcpb`), **Claude Code**,
and the **ChatGPT desktop app** (stdio). Claude and ChatGPT on web/mobile cannot
reach `localhost` and are out of scope — they need the Phase 2 cloud relay.

## How it finds the app

There is no configuration, no port to pick and no key to paste. #109 made the
worker API authenticated and had it publish a handshake before it starts
listening:

```
$APPDATA_PATH/runtime.json   →   { "port": 3004, "token": "…", "pid": 4711 }
```

`$APPDATA_PATH` is `~/Library/Application Support/StoryFolder` on macOS and
`%APPDATA%\StoryFolder` on Windows. The file is mode `0600`; on Windows that is
a no-op and `%APPDATA%` ACL inheritance is the protection, the same protection
the database already relies on.

The server reads that file **on every call**, never once at startup. A worker
that dies is restarted (#63) and the replacement issues a *new* token, so a
cached one would 401 for the rest of the session. Every request carries:

| Header | Why |
|---|---|
| `x-storyfolder-token` | #109. Without it the worker answers 401. |
| `x-storyfolder-client: storyfolder-mcp` | #122. Becomes the `actor` on every restore point a write mints, which is what badges an agent's edits in **Version history**. |

If the file is absent, or nothing answers on its port, every tool fails with a
sentence saying StoryFolder is not running and what to do about it. That is the
error users hit most, so it is written for a person and tested as its own
concern (`tests/unit/mcp/storyfolder-closed.spec.js`).

> **Note — `runtime.json` is never deleted.** #99 §1 asked for it to be removed
> on quit and on worker death; #109 shipped the write half only, and nothing in
> `ApiController` unlinks it. So the normal state of a machine where StoryFolder
> has been used and closed is *a handshake file naming a dead port*. This server
> handles that by checking the recorded `pid`: no process, no worker, and the
> user gets "StoryFolder is not running" rather than the vaguer "not responding".
> Implementing the removal in the app would make that check redundant but not
> wrong — pid reuse and crashed workers would still land here.

## Undo comes free, and that is on purpose

Every write goes through `PUT /api/{model}/:id` — never the bulk route, which
#123 §3 rules out because it has no snapshot and no intent boundary. The per-id
route runs #122's capture middleware, so **an agent's edits land in the same
Version history the user's own edits do**, labelled and attributed. A multi-shot
annotation is sent as one request and is therefore one undo step, exactly like a
multi-select edit made in the app.

Two consequences worth knowing:

- Renaming a project writes `title`, not `name`. `name` is a VIRTUAL and #114
  answers 400 for a body naming one. `update_project` catches that in its own
  input validation so the user gets a sentence instead of a status code.
- Replaying a value costs nothing. #114 made an identical-value `PUT` a true
  no-op: no write, no watcher, no broadcast, no restore point.

## Live sync

MCP writes reach an open StoryFolder window with **no code here at all**. They go
through the routes the renderer uses, so #116's `broadcastMutation` fires and
`App.vue` applies it to the store. #123 §10 records this as the live-sync
mechanism for MCP writes; nothing in this package adds a websocket event type.

The one place this package *consumes* the socket is `get_export_job(wait_seconds)`,
which subscribes to the same broadcast the renderer does and returns as soon as
the job's ordinary `Modify` event arrives — instead of the model burning a turn
per poll on an export that runs for minutes. It falls back to polling if the
socket cannot be opened, so it is an optimisation and never a dependency.

## Tools

| Tool | Writes? | Through |
|---|---|---|
| `list_projects` | — | `GET /api/videos` |
| `get_project` | — | `GET /api/videos/:id` + metadata schema |
| `update_project` | ✎ | **`PUT /api/videos/:id`** (`title` / `description` / `FolderId`) |
| `get_frames` | — | `GET /api/videos/:id/frames/:seekTime/:width` |
| `search_frames` | — | `GET /api/videos/:id`, or `GET /api/videos` for the whole library (filters `frameMetadata` by #119's range rule) |
| `list_folders` | — | `GET /api/folders` |
| `list_metadata_fields` | — | `GET /api/metadata-categories`, `GET /api/metadata-fields` |
| `create_metadata_field` | ✎ | `POST /api/metadata-categories`, `POST /api/metadata-fields` |
| `set_frame_metadata` | ✎ | **`PUT /api/videos/:id/metadata`** |
| `set_video_metadata` | ✎ | **`PUT /api/videos/:id`** (`metadata`, merged then written whole) |
| `set_video_metadata_batch` | ✎ | **`PUT /api/videos/:id`** per project, same fields |
| `import_video` | ✎ | `POST /api/videos/inspect-source` then `POST /api/videos` |
| `create_export` | ✎ | `POST /api/exportJobs` |
| `get_export_job` | — | `GET /api/exportJobs/:id` (+ websocket wait) |
| `list_export_jobs` | — | `GET /api/exportJobs` |
| `list_versions` | — | `GET /api/videos/:id/restore-points` |

The bold rows are the ones that mint restore points.

`set_video_metadata` is the video-level twin of `set_frame_metadata`: fields
whose category has `target: "video"` live in `videos.metadata`
(`{ "<MetadataField.id>": value }`, #186), one object per project rather than
one per frame. Every field named is resolved and validated against its own
domain — same table `set_frame_metadata` checks against — before anything is
written; the whole `videos.metadata` column is then read, merged in memory,
and written back in ONE `PUT`, so several fields land as one restore point and
either all of them land or none do. `set_video_metadata_batch` applies the
same fields to a list of projects: an unknown field or a shot-level field is
checked once and fails the whole call before any project is touched, but a
per-project problem (unknown id, write failure) is isolated — the response
reports `ok: true/false` per project rather than aborting the batch.

`get_frames` returns MCP `image` content blocks **and** a `structuredContent`
block naming every frame's timecode and shot. Some clients drop image results
entirely (a reported ChatGPT connector bug), so the structured block is what
keeps the tool usable there. Frames default to 512 px wide to control token cost.

Every shot is reported with two identifiers (#149): `index`, the 0-based
addressing key these tools accept, and `shot_number`, the 1-based number the app
and its exported filenames use (`Images/Shot-1.png` is `index: 0`). `get_frames`
and `set_frame_metadata` accept either `shots` or `shot_numbers`, so a model
never has to do the arithmetic, and an out-of-range value is reported in both
scales.

`search_frames` searches one project, or the whole library when `project_id` is
omitted — one `GET /api/videos` rather than a call per project, because that
route already carries `shots` and `frameMetadata` for every project (#151). A
field title that two categories both use matches both; `criteria` ANDs several
conditions, and `match` chooses between `equals`, `contains`, `one_of`, `is_set`
and `not_set` — plus, on a `number` or `rating` field, `greater_than`,
`less_than` and `between` (#338), e.g.
`{ field: "Extras", match: "greater_than", value: 3 }` for "shots with more
than 3 extras".

Exports never block. `create_export` returns a `job_id` immediately; the existing
watchdog and interrupted-job recovery (#42, #63) cover the failure modes.
`location` is optional (#150): omitted, the export goes to
`~/Documents/StoryFolder Exports/<project>` and the response reports the absolute
path used — a model cannot see the user's filesystem, and requiring it produced
invented paths like `/home/claude/storyfolder_exports` that could never exist.
Whatever its source, the folder is proved creatable and writable before a job is
queued, and a finished job lists the files it wrote so a job that produced
nothing does not read as a success.

### Read-only mode

`STORYFOLDER_MCP_READ_ONLY=1` hides every tool that writes, so an assistant can
look at the library and not touch it. It is exposed as a checkbox in the Claude
Desktop extension settings (`user_config.read_only` in `manifest.json`). Hiding
beats annotating: a tool that is not listed cannot be called by mistake or by
persuasion.

## Install

### Claude Desktop — one click

Build the bundle, then double-click it (or drag it onto Settings → Extensions):

```bash
npm run build:mcpb          # from the repo root
# → mcp/build/storyfolder-<version>.mcpb        Claude Desktop
# → mcp/build/storyfolder-codex-<version>.zip   Codex — the same tree, plain .zip
```

One command, one staged tree, two files. The `.zip` is built from the finished
`.mcpb`, so the two can never hold different code.

The bundle is **unsigned**. `mcpb sign` needs a code-signing certificate, which
is a credential and therefore a human step; Claude Desktop installs an unsigned
extension with an "unverified" notice.

### Codex — download and unzip

A `.mcpb` is a ZIP with a different extension, but nobody should have to know
that, so Codex gets its own file rather than a rename instruction. Unzip
`storyfolder-codex-<version>.zip`, keep the `storyfolder-connector/` folder it
produces, and point Codex at it:

```bash
codex mcp add storyfolder -- node /path/to/storyfolder-connector/src/index.js
```

That folder name is deliberately unversioned, so upgrading is "unzip the new one
over the old one" and the path Codex already has on file keeps working.

### Claude Code

```bash
claude mcp add storyfolder -- node /path/to/StoryFolderDesktop/mcp/src/index.js
```

### ChatGPT desktop

Settings → MCP servers → STDIO, command `node`, argument
`/path/to/StoryFolderDesktop/mcp/src/index.js`.

## Layout

```
src/runtime.js     reads runtime.json — the #109 handshake, never cached
src/client.js      node http, token + client headers, 401-retry on worker restart
src/errors.js      the sentences a user reads, including "StoryFolder is closed"
src/tools.js       every tool: JSON Schema + handler. No MCP SDK.
src/live-sync.js   subscribes to the existing broadcast; adds no event type
src/result.js      MCP result shaping (text + structuredContent, isError)
src/server.js      the only file that imports the MCP SDK
src/index.js       stdio entry point
```

`src/server.js` and `src/index.js` are the only files that touch the SDK, which
is ESM-first — its CommonJS build is reachable only through an `exports` map that
jest's resolver does not follow. Everything else is plain Node and is tested
in-process. The SDK layer is covered by `tests/unit/mcp/stdio-protocol.spec.js`,
which spawns the real entry point under real Node and speaks JSON-RPC to it.

## Tests

```bash
npx vue-cli-service test:unit tests/unit/mcp      # or: npm run test:mcp
```

- `tools.spec.js` — every tool against a **live worker API** (#124's in-process
  harness: real router, real sequelize, real migrations, real websocket), over a
  genuine loopback HTTP connection. This is where the attribution, undo,
  broadcast and no-op assertions live.
- `storyfolder-closed.spec.js` — the three closed states, each against a
  throwaway HTTP server so the state is produced deliberately.
- `stdio-protocol.spec.js` — the entry point as a child process: handshake,
  `tools/list`, `tools/call`, `ping`, stdout cleanliness, read-only gate.
