Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .claude/skills/mountea-dialoguer/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
name: mountea-dialoguer
description: Author and edit dialogue content through the Mountea Dialoguer MCP server - projects, dialogues and node graphs (NPC lines, player options, return/complete/delay/child-graph nodes), participants, categories, decorators, conditions, translations, validation and .mnteadlg export. Use whenever the user wants to write, restructure, translate, review or export game dialogue, NPC conversations, player choices, speakers or localization in Dialoguer, even if they do not say "MCP".
---

# Mountea Dialoguer (MCP)

Mountea Dialoguer is a dialogue graph editor. The MCP server edits the same projects the web app shows. Everything the UI can do is available as a tool, except deleting whole projects or dialogues (there is deliberately no tool for that; ask the user to do it in the app).

## Connecting

Tools are named `list_projects`, `create_dialogue`, `set_graph`, ... If they are not available, the server is not connected. Tell the user how:

- Claude Code: `claude mcp add --transport http mountea-dialoguer https://<mcp-host>/mcp --header "Authorization: Bearer <MCP_TOKEN>"`
- claude.ai custom connector (cannot send headers): URL `https://<mcp-host>/mcp/<MCP_TOKEN>`, no other auth.
- Local: `MCP_TOKEN=<24+ chars> npm run mcp` (port 8081, same `DATA_DIR` as the app server).

The token is a secret. Never print it into dialogue content, commits or chat.

## How the data works

- A project holds dialogues, participants (speakers), categories (a tree the participants live in), decorators and conditions (definitions), and translations.
- A dialogue is a graph. Every dialogue has exactly one Start node (`start`); it has one outgoing connection.
- Text is localized. Tools write and read the project's **default locale** unless you pass `locale`. Other languages go through `set_text` after `update_project addLocales`.
- Names (project, dialogue, participant, category, decorator, condition) are converted to PascalCase: spaces are removed, only letters, numbers, `_` and `-` survive. Participants and categories are max 16 characters; category depth max 5; names are unique inside their category tree.
- **Names:** participant and category names are 1-16 letters or digits only (the Unreal importer enforces the same limit). The tools reject anything else.
- The app and the MCP share the project as a chain of revisions. Every MCP call adds a revision; open tabs pick it up on their next sync (focus, or within 30 s). An open editor with nothing unsaved reloads by itself; one with unsaved edits asks. If the user saved at the same moment, the app shows a "Synchronization conflicts" panel and keeps both versions. Never claim the user "already sees" a change before the sync has run.

## Node types

| type | use | notes |
|---|---|---|
| `leadNode` | NPC speaks | needs `participant`; `rows` are the spoken lines |
| `answerNode` | player option | `selectionTitle` = the clickable option label; `rows` = what the player then says (first row is shown next to the label in preview) |
| `returnNode` | jump back to an earlier node | `targetNode`; no outgoing connections |
| `openChildGraphNode` | continue in another dialogue | `targetDialogue` (name or id); no outgoing |
| `completeNode` | ends the conversation | needs `participant`; no outgoing |
| `delayNode` | pause | `duration` 0.1-30 s |

Rows can carry optional timing: `executionBehaviour` (`automatic` default, `awaitInput` = click to continue, `stopping`), `durationMode` (`duration` default, `override`, `add`, `calculate`) and `durationOverride` (seconds). Omit them for normal rows.

Required fields: `displayName` (max 64) on all but delay; `participant` on lead/answer/complete. A player node without `selectionTitle` has no option label - always set it.

**Option labels.** The Unreal plugin shows `selectionTitle` as the clickable choice (falling back to the display name when it is empty). Write the real label in `selectionTitle` and a short internal name in `displayName`; the two are exported as separate strings.

**Known plugin limits.** The Return node's "Auto Complete Selected Node" option exists only in Unreal (it cannot be authored or imported from here); set it in the Unreal graph after import if a design needs it. Decorator and condition properties can only be `string`, `number` or `boolean` (`int`, `float`, `bool` are accepted as aliases); the importer rejects anything else. Mention these when a design depends on them.

## Workflow

1. `list_projects` -> `get_project` (shows participants, categories, decorators, conditions, locales, dialogues). Reuse what exists.
2. Create missing building blocks first: `create_category`, `create_participant`, `create_decorator`, `create_condition`. Participants must exist before a node can use them.
3. `create_dialogue`, then build the graph with **one `set_graph` call** (`mode: "replace"`). Give every node a short `ref`; use refs in `edges` and `targetNode`; `"start"` is the Start node. Layout is automatic.
4. `validate_dialogue`. Fix every error; read the warnings (unreachable nodes, missing option labels, unknown participants).
5. Iterate with `get_dialogue` (ids come from here) and `update_node`, `add_node`, `connect_nodes`, `delete_edge`, `update_edge`, `delete_node`.
6. Translate: `update_project addLocales ["de"]`, `list_missing_translations`, `set_text`.
7. `export_dialogue` (or `export_project`) gives the archive for the Unreal plugin (format `mountea.archive.v3`, link valid 24 h). Older plugin builds cannot read it.

Typical conversation shape: Start -> NPC greeting -> several player options (each an `answerNode`) -> NPC reply -> back to the options with a `returnNode`, plus one option leading to a `completeNode`. Options that end the conversation are shown last in preview.

## Writing rules

- Keep lines short and speakable; one idea per row. Use several rows instead of one long block (each row has its own `duration`, default 3 s; set longer for long lines).
- Option labels are actions or intents ("Ask about the curse"), spoken lines are what the character actually says.
- Stay in the project's existing voice: read a couple of existing nodes with `get_dialogue` before adding to a dialogue.
- Do not invent participants, categories or decorators silently; say what you created.

## Safety

- Anything *inside* a dialogue can be deleted: nodes (`delete_node`, `cascade`), connections (`delete_edge`), rows (`update_node` with `rows`), translations, or the whole content (`clear_dialogue`). Only the dialogue itself and the project cannot be deleted.
- Destructive tools exist for nodes, edges, participants, categories, decorators and conditions. `delete_node` with `cascade: true` removes everything below the node: say what will go before doing it. `delete_participant`/`delete_category`/`delete_*` of definitions refuse while in use; do not reach for `force` without telling the user.
- Every write makes a server-side backup (last 10 per project). If something goes wrong, tell the user; do not try to repair data by rewriting a whole graph blindly.
- If a tool errors, read the message: it names the rule that failed (name pattern, uniqueness, depth, missing participant, structural rule). Fix the input and retry once; do not loop.
- `set_graph` with `mode: "replace"` replaces every node of that dialogue except Start. Use `merge` to add to an existing graph.

## Tool reference

Projects: `list_projects`, `get_project`, `create_project`, `update_project` (name, description, version, default/added/removed locales).
Dialogues: `list_dialogues`, `create_dialogue`, `update_dialogue`, `get_dialogue`, `validate_dialogue`.
Graph: `add_node`, `update_node`, `delete_node`, `connect_nodes`, `update_edge` (conditions), `delete_edge`, `set_graph`, `auto_layout`, `clear_dialogue` (empties a dialogue, keeps it).
Text: `set_text`, `list_missing_translations`.
People: `list_participants`, `create_participant`, `update_participant` (renames cascade to nodes), `delete_participant`, `set_participant_thumbnail`, `list_categories`, `create_category`, `update_category`, `delete_category`.
Definitions: `list/create/update/delete_decorator` and `_condition`. Decorators go on nodes (`decorators: [{name, values}]`), conditions on connections (`conditions: {mode: "all"|"any", rules: [{name, values, negate}]}`).
Export: `export_dialogue`, `export_project`.

Row input: a string or `{text, duration}`. `rows` always replaces all rows of the node.
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
data
.git
DocumentationSource
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ module.exports = {
plugins: ['react-refresh'],
overrides: [
{
files: ['electron/**/*.{js,cjs,mjs}', 'scripts/**/*.{js,cjs,mjs}', 'tests/**/*.{js,cjs,mjs}', '*.config.{js,cjs,mjs}', '.eslintrc.cjs'],
files: ['electron/**/*.{js,cjs,mjs}', 'scripts/**/*.{js,cjs,mjs}', 'tests/**/*.{js,cjs,mjs}', 'mcp/**/*.{js,mjs}', 'server/**/*.mjs', '*.config.{js,cjs,mjs}', '.eslintrc.cjs'],
env: { node: true },
},
],
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ dist-ssr
/TestingData/TestingExport_Broken/*
*.kra~
.tanstack
/.claude
/.claude/*
!/.claude/skills/
/data
/.vscode
/.jetbrains
/.rider
Expand Down
30 changes: 30 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Self-hosted web edition: build the app against the bundled sync server, then run that server.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
# The desktop-only dependencies are not needed here; do not download Electron.
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1
RUN npm ci
COPY . .
# Sync with the bundled server instead of a cloud provider (see SELF_HOSTED.md).
ENV VITE_SYNC_SERVER=true
RUN npm run build

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data DIST_DIR=/app/dist
COPY --from=build /app/dist ./dist
COPY server ./server
# MCP server (own process/port) and the few pure modules of the web app it reuses (loaded through mcp/register.mjs).
COPY package.json ./package.json
COPY mcp ./mcp
COPY src/lib/persistence/canonicalProject.js src/lib/persistence/schema.js ./src/lib/persistence/
COPY src/lib/localization ./src/lib/localization
COPY src/lib/domainIntegrity.js src/lib/rowTiming.js src/lib/assetNaming.js ./src/lib/
COPY src/helpers/nameHelpers.js ./src/helpers/nameHelpers.js
COPY src/config/dialogueNodes.json src/config/edgeConditions.js ./src/config/
VOLUME /data
EXPOSE 8080 8081
# Runs as root on purpose: a bind-mounted ./data is created root-owned by Docker and a non-root user could not write to it.
# nosemgrep: dockerfile.security.missing-user.missing-user
CMD ["node", "server/index.mjs"]
55 changes: 55 additions & 0 deletions SELF_HOSTED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Self-hosted web edition and MCP server

An optional way to run Mountea Dialoguer as a web app on your own machine (a server, a Raspberry Pi, a NAS) and use it from every device. It adds three things and changes nothing for the desktop app or the Google Drive and Steam providers:

- **`server/`**: a dependency-free Node server. It serves the built web app and is the file store behind a new `server` sync provider (`src/lib/sync/providers/`). The provider speaks the same immutable-revision protocol as the other providers, in plain text and without a login.
- **`mcp/`**: a dependency-free MCP server (Streamable HTTP) that lets an LLM author projects through the same revision store. A Claude skill that teaches the workflow is in `.claude/skills/mountea-dialoguer/`.
- **`Dockerfile` and `docker-compose.yml`** to run both.

Nothing here is active unless the app is built with `VITE_SYNC_SERVER=true` (the Dockerfile does that). Access control is deliberately left to whatever sits in front of the server (a reverse proxy, Cloudflare Access, a VPN).

## Run it

```
docker compose up -d --build
```

Open http://localhost:8080. The app stores one file per revision in `./data/sync/`. Without Docker:

```
VITE_SYNC_SERVER=true npm run build
npm start # PORT (default 8080), DATA_DIR (default ./data), DIST_DIR
```

How the app behaves in this edition: it connects to the server at start (there is nothing to log in to), pulls what other devices or the MCP server added before the first paint, pushes every change a moment after it is committed, and polls every 30 seconds. Competing edits of one project end up in the existing conflict panel.

## MCP server

Create a `.env` next to `docker-compose.yml`:

```
MCP_TOKEN=<output of: openssl rand -hex 24>
```

Without a token (at least 24 characters) the `mcp` container refuses to start; the app is not affected. Locally: `MCP_TOKEN=<token> npm run mcp` (`MCP_PORT`, default 8081; `DATA_DIR` must be the server's).

- It reads a project's head revision, changes a copy, validates it with the app's own `canonicalProject` code (`mcp/register.mjs` lets Node load those modules) and writes a new revision. The revision history is the backup.
- 39 tools: projects, dialogues, nodes and connections, participants, categories, decorators, conditions, row timing, translations, validation and `.mnteadlg` / `.mnteadlgproj` export. There is **no tool to delete a project or a dialogue itself**; content inside a dialogue can be deleted.
- Auth: `Authorization: Bearer <token>`, or `https://host/mcp/<token>` for clients that cannot send headers. Failed attempts are rate limited and every write is logged to `data/mcp-audit.log`.
- It is a separate process on its own port so that it can be published on its own hostname when the app itself is behind an access proxy.
- Claude Code: `claude mcp add --transport http mountea-dialoguer https://<host>/mcp --header "Authorization: Bearer <token>"`.

## Tests

```
npm run test:mcp # 20 tests: protocol, auth, authoring rules, export, revision history
PORT=8088 DATA_DIR=/tmp/td node server/index.mjs # then, for the specs that need a running server:
PLAYWRIGHT_BASE_URL=http://127.0.0.1:8088 npx playwright test autosave
```

`tests/e2e/mcp.spec.js` additionally needs `MCP_URL` and `MCP_TOKEN` (see the comment at the top of the file).

## Known limits

- Every save adds a revision file and each page load downloads every revision once. A cleanup or compaction step would be a natural next addition.
- The plain-text store is only as private as the network in front of it.
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
services:
dialoguer:
build: .
image: mountea-dialoguer:latest
container_name: mountea-dialoguer
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data

# MCP server: lets Claude edit projects. Own port and hostname, own auth (MCP_TOKEN).
# Put the app (8080) behind Cloudflare Access; publish this one (8081) on a separate hostname without Access.
mcp:
image: mountea-dialoguer:latest
container_name: mountea-dialoguer-mcp
restart: unless-stopped
command: ["node", "--import", "./mcp/register.mjs", "mcp/server.mjs"]
depends_on:
- dialoguer
environment:
MCP_TOKEN: ${MCP_TOKEN:-} # set in .env: openssl rand -hex 24. Without it the MCP container exits (by design).
ports:
- "8081:8081"
volumes:
- ./data:/data
33 changes: 33 additions & 0 deletions mcp/alias-hooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
const CANDIDATES = ['', '.js', '.jsx', '.json', '/index.js'];

function resolveFile(base) {
for (const suffix of CANDIDATES) {
const file = base + suffix;
if (fs.existsSync(file) && fs.statSync(file).isFile()) return file;
}
return null;
}

export async function resolve(specifier, context, next) {
let file = null;
if (specifier.startsWith('@/')) {
file = resolveFile(path.join(SRC, specifier.slice(2)));
} else if ((specifier.startsWith('./') || specifier.startsWith('../')) && !path.extname(specifier) && context.parentURL?.startsWith('file:')) {
file = resolveFile(path.resolve(path.dirname(fileURLToPath(context.parentURL)), specifier));
}
if (file) return { url: pathToFileURL(file).href, shortCircuit: true };
return next(specifier, context);
}

export async function load(url, context, next) {
if (url.startsWith('file:') && url.endsWith('.json')) {
const source = fs.readFileSync(fileURLToPath(url), 'utf8');
return { format: 'module', source: `export default ${source};`, shortCircuit: true };
}
return next(url, context);
}
Loading