diff --git a/.claude/skills/mountea-dialoguer/SKILL.md b/.claude/skills/mountea-dialoguer/SKILL.md new file mode 100644 index 0000000..814b72e --- /dev/null +++ b/.claude/skills/mountea-dialoguer/SKILL.md @@ -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 --header "Authorization: Bearer "` +- claude.ai custom connector (cannot send headers): URL `https:///mcp/`, 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. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5903021 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +data +.git +DocumentationSource diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fd36186..4cd1da6 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -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 }, }, ], diff --git a/.gitignore b/.gitignore index f096a1c..5cfcf66 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,9 @@ dist-ssr /TestingData/TestingExport_Broken/* *.kra~ .tanstack -/.claude +/.claude/* +!/.claude/skills/ +/data /.vscode /.jetbrains /.rider diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52551f6 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/SELF_HOSTED.md b/SELF_HOSTED.md new file mode 100644 index 0000000..3be0cfc --- /dev/null +++ b/SELF_HOSTED.md @@ -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= +``` + +Without a token (at least 24 characters) the `mcp` container refuses to start; the app is not affected. Locally: `MCP_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 `, or `https://host/mcp/` 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:///mcp --header "Authorization: Bearer "`. + +## 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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c7cf77f --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/mcp/alias-hooks.mjs b/mcp/alias-hooks.mjs new file mode 100644 index 0000000..0f1adc4 --- /dev/null +++ b/mcp/alias-hooks.mjs @@ -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); +} diff --git a/mcp/lib/export.mjs b/mcp/lib/export.mjs new file mode 100644 index 0000000..a6cff41 --- /dev/null +++ b/mcp/lib/export.mjs @@ -0,0 +1,87 @@ +// Archive export (.mnteadlg / .mnteadlgproj, format "mountea.archive.v3"), ported from exportProjectArchive in +// src/lib/persistence/projectArchive.js (which needs JSZip and the browser). Same layout, same record naming. +// tests/e2e/mcp.spec.js compares this output with the UI export, so keep both in step. +import { + RECORD_TABLES, + canonicalizeProject, + decodeMediaBase64, + requireValidProject, +} from '../../src/lib/persistence/canonicalProject.js'; +import { ToolError } from './store.mjs'; +import { buildZip } from './zip.mjs'; + +const LIMITS = { compressed: 25 * 1024 * 1024, json: 5 * 1024 * 1024, entries: 1000, dialogues: 250, expanded: 128 * 1024 * 1024 }; +const encode = (value) => Buffer.from(JSON.stringify(value)); +const filename = (name, id) => `${String(name || 'record').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 70)}--${encodeURIComponent(id)}`; + +async function exportArchive(input, { kind, dialogueId }) { + let snapshot; + try { + snapshot = requireValidProject(await canonicalizeProject(input)); + } catch (error) { + throw new ToolError(`Export blocked: ${error.message}`); + } + if (snapshot.dialogues.length > LIMITS.dialogues) throw new ToolError('Project has too many dialogues for one archive.'); + const files = []; + const manifest = { format: 'mountea.archive.v3', kind, dialogueId, project: 'project.json', records: [], media: [] }; + let expanded = 0; + const add = (path, bytes) => { + if (path.endsWith('.json') && bytes.length > LIMITS.json) throw new ToolError(`A record exceeds the 5 MiB JSON limit: ${path}`); + expanded += bytes.length; + if (expanded > LIMITS.expanded || files.length + 1 > LIMITS.entries) throw new ToolError('Export exceeds the archive limits.'); + files.push({ name: path, data: bytes }); + }; + for (const node of snapshot.nodes) { + for (const row of node.data?.dialogueRows || []) { + if (!row.audioFile) continue; + const path = `media/${filename(row.audioFile.name, JSON.stringify([node.dialogueId, node.id, row.id]))}`; + add(path, Buffer.from(decodeMediaBase64(row.audioFile.base64))); + delete row.audioFile.base64; + manifest.media.push({ path, dialogueId: node.dialogueId, nodeId: node.id, rowId: row.id }); + } + } + for (const participant of snapshot.participants) { + if (!participant.thumbnail?.base64) continue; + const path = `media/thumbnails/${encodeURIComponent(participant.id)}`; + add(path, Buffer.from(decodeMediaBase64(participant.thumbnail.base64))); + delete participant.thumbnail.base64; + manifest.media.push({ path, participantId: participant.id }); + } + add(manifest.project, encode(snapshot.project)); + for (const table of RECORD_TABLES) { + for (const [index, record] of snapshot[table].entries()) { + const path = `${table}/${filename(record.name || record.key || table, `${record.dialogueId || ''}:${record.id || record.key}:${index}`)}.json`; + add(path, encode(record)); + manifest.records.push({ table, path }); + } + } + add('manifest.json', encode(manifest)); + const zip = buildZip(files); + if (zip.length > LIMITS.compressed) throw new ToolError('Export exceeds the 25 MiB archive limit.'); + return zip; +} + +export function buildProjectExport(snapshot) { + return exportArchive(snapshot, { kind: 'project' }); +} + +/** One dialogue plus every child graph it opens (transitively), like the Export button. */ +export function buildDialogueExport(snapshot, dialogueId) { + if (!snapshot.dialogues.some((d) => d.id === dialogueId)) throw new ToolError(`Dialogue "${dialogueId}" not found`); + const copy = structuredClone(snapshot); + const selected = new Set([dialogueId]); + let changed = true; + while (changed) { + changed = false; + for (const node of copy.nodes) { + if (selected.has(node.dialogueId) && node.data?.targetDialogue && !selected.has(node.data.targetDialogue)) { + selected.add(node.data.targetDialogue); + changed = true; + } + } + } + for (const table of ['dialogues', 'nodes', 'edges', 'localizedStrings']) { + copy[table] = copy[table].filter((row) => selected.has(table === 'dialogues' ? row.id : row.dialogueId)); + } + return exportArchive(copy, { kind: 'dialogue', dialogueId }); +} diff --git a/mcp/lib/graph.mjs b/mcp/lib/graph.mjs new file mode 100644 index 0000000..7706b0d --- /dev/null +++ b/mcp/lib/graph.mjs @@ -0,0 +1,313 @@ +// Dialogue graph load/save/validate. Mirrors saveDialogueGraph / loadDialogueGraph in the app, but on a snapshot object. +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { + DEFAULT_LOCALE, + allocateDialogueLocalizationSlug, + ensureDialogueLocalizationSlug, + filterLocalizedEntriesByDialogue, + materializeLocalizedNodes, + normalizeLocaleTag, + normalizeProjectLocalizationConfig, + prepareLocalizedNodesAndEntries, + validateLocalizedEntriesForDialogue, +} from '../../src/lib/localization/stringTable.js'; +import { createConditionInstance, getConditionDefaultValues } from '../../src/config/edgeConditions.js'; +import { ToolError, uuid } from './store.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const NODE_DEFS = JSON.parse(fs.readFileSync(path.join(here, '../../src/config/dialogueNodes.json'), 'utf8')); +export const START_NODE_ID = '00000000-0000-0000-0000-000000000001'; +export const CREATABLE_TYPES = Object.values(NODE_DEFS).filter((d) => d.creatable).map((d) => d.type); + +export function projectLocales(snapshot) { + const loc = normalizeProjectLocalizationConfig(snapshot.project?.localization || {}); + return { defaultLocale: loc.defaultLocale || DEFAULT_LOCALE, supportedLocales: loc.supportedLocales }; +} + +export function getDialogue(snapshot, dialogueId) { + const dialogue = snapshot.dialogues.find((d) => d.id === dialogueId); + if (!dialogue) { + throw new ToolError(`Dialogue "${dialogueId}" not found in this project. Use list_dialogues.`); + } + return dialogue; +} + +export function defaultNode(type) { + const def = NODE_DEFS[type]; + if (!def) throw new ToolError(`Unknown node type "${type}". Types: ${Object.keys(NODE_DEFS).join(', ')}`); + return JSON.parse(JSON.stringify(def.defaultData || {})); +} + +export function startNode() { + return { + id: START_NODE_ID, + type: 'startNode', + position: { x: 0, y: 0 }, + deletable: false, + data: defaultNode('startNode'), + }; +} + +const normalizeRow = (row = {}) => ({ + ...row, + id: row.id || uuid(), + text: row.text ?? '', + duration: typeof row.duration === 'number' ? row.duration : 3.0, + audioFile: row.audioFile || null, +}); + +/** Nodes and edges of a dialogue in "editor shape" (text materialised for `locale`). */ +export function loadGraph(snapshot, dialogueId, locale) { + const dialogue = getDialogue(snapshot, dialogueId); + const { defaultLocale } = projectLocales(snapshot); + const entries = filterLocalizedEntriesByDialogue(snapshot.localizedStrings, dialogueId); + const stored = snapshot.nodes.filter((n) => n.dialogueId === dialogueId); + const base = stored.some((n) => n.id === START_NODE_ID) ? stored : [{ ...startNode(), dialogueId }, ...stored]; + const nodes = materializeLocalizedNodes({ + nodes: base, + dialogueId, + dialogueSlug: ensureDialogueLocalizationSlug(dialogue), + locale: normalizeLocaleTag(locale, defaultLocale), + defaultLocale, + stringEntries: entries, + }).map((node) => ({ + ...node, + data: { + ...node.data, + dialogueRows: Array.isArray(node.data?.dialogueRows) ? node.data.dialogueRows.map(normalizeRow) : node.data?.dialogueRows, + }, + })); + const edges = snapshot.edges.filter((e) => e.dialogueId === dialogueId).map((e) => ({ ...e })); + return { dialogue, nodes, edges }; +} + +function stripLocalizedText(data = {}) { + const next = { ...data }; + if (next.displayNameKey) delete next.displayName; + if (next.selectionTitleKey) delete next.selectionTitle; + if (Array.isArray(next.dialogueRows)) { + next.dialogueRows = next.dialogueRows.map((row) => { + const r = { ...row }; + if (r.textKey) delete r.text; + return r; + }); + } + return next; +} + +/** Replaces the dialogue's nodes, edges and localized strings. Writes text into `locale` (default locale unless told otherwise). */ +export function saveGraph(snapshot, dialogueId, nodes, edges, locale) { + const dialogue = getDialogue(snapshot, dialogueId); + const { defaultLocale } = projectLocales(snapshot); + const activeLocale = normalizeLocaleTag(locale, defaultLocale); + const slug = allocateDialogueLocalizationSlug(dialogue, snapshot.dialogues); + dialogue.localizationSlug = slug; + const existing = filterLocalizedEntriesByDialogue(snapshot.localizedStrings, dialogueId); + + const prepared = prepareLocalizedNodesAndEntries({ + projectId: snapshot.project.id, + dialogueId, + dialogueSlug: slug, + nodes: nodes.map((n) => ({ + ...n, + data: { ...n.data, dialogueRows: Array.isArray(n.data?.dialogueRows) ? n.data.dialogueRows.map(normalizeRow) : n.data?.dialogueRows }, + })), + locale: activeLocale, + existingEntries: existing, + }); + if (prepared.diagnostics.some((d) => d.type === 'duplicate_key')) { + throw new ToolError('Localization key collision: two nodes or rows produce the same key. Give them different display names.'); + } + const validation = validateLocalizedEntriesForDialogue({ + nodes: prepared.nodes, + entries: prepared.entries, + defaultLocale, + projectId: snapshot.project.id, + dialogueId, + dialogueSlug: slug, + projectEntries: snapshot.localizedStrings, + }); + if (!validation.valid) { + const first = validation.errors.slice(0, 3).map((e) => `${e.type}${e.key ? ` (${e.key})` : ''}`).join(', '); + throw new ToolError(`Localization validation failed: ${first}`); + } + + snapshot.localizedStrings = snapshot.localizedStrings.filter((e) => e.dialogueId !== dialogueId).concat(prepared.entries); + snapshot.nodes = snapshot.nodes + .filter((n) => n.dialogueId !== dialogueId) + .concat(prepared.nodes.map((n) => ({ ...n, dialogueId, data: stripLocalizedText(n.data) }))); + snapshot.edges = snapshot.edges.filter((e) => e.dialogueId !== dialogueId).concat(edges.map((e) => ({ ...e, dialogueId }))); + dialogue.localizationVersion = 2; + dialogue.modifiedAt = new Date().toISOString(); +} + +// ---------- decorators / conditions ---------- + +export function buildDecoratorInstances(snapshot, list = []) { + return list.map((item) => { + const name = typeof item === 'string' ? item : item?.name; + const def = snapshot.decorators.find((d) => d.name.toLowerCase() === String(name || '').toLowerCase()); + if (!def) { + throw new ToolError(`Decorator "${name}" is not defined. Defined: ${snapshot.decorators.map((d) => d.name).join(', ') || '(none)'}. Use create_decorator first.`); + } + const values = { ...getConditionDefaultValues(def), ...(item?.values || {}) }; + for (const key of Object.keys(values)) { + if (!(def.properties || []).some((p) => p.name === key)) throw new ToolError(`Decorator "${def.name}" has no property "${key}"`); + } + return { id: def.id, name: def.name, values }; + }); +} + +export function buildConditionInstances(snapshot, list = []) { + return list.map((item) => { + const name = typeof item === 'string' ? item : item?.name; + const def = snapshot.conditions.find((c) => c.name.toLowerCase() === String(name || '').toLowerCase()); + if (!def) { + throw new ToolError(`Condition "${name}" is not defined. Defined: ${snapshot.conditions.map((c) => c.name).join(', ') || '(none)'}. Use create_condition first.`); + } + const instance = createConditionInstance(def); + instance.values = { ...instance.values, ...(item?.values || {}) }; + instance.negate = Boolean(item?.negate); + for (const key of Object.keys(instance.values)) { + if (!(def.properties || []).some((p) => p.name === key)) throw new ToolError(`Condition "${def.name}" has no property "${key}"`); + } + return instance; + }); +} + +export function makeEdge(source, target, snapshot, conditions) { + return { + id: `${source}-${target}`, + source, + target, + type: 'conditionEdge', + markerEnd: { type: 'arrowclosed' }, + data: { + conditions: { + mode: conditions?.mode === 'any' ? 'any' : 'all', + rules: buildConditionInstances(snapshot, conditions?.rules || []), + }, + }, + }; +} + +// ---------- structural rules ---------- + +/** Hard rules the editor enforces while connecting. Throws ToolError. */ +export function assertEdgeAllowed(nodes, edges, source, target) { + const from = nodes.find((n) => n.id === source); + const to = nodes.find((n) => n.id === target); + if (!from) throw new ToolError(`Source node "${source}" does not exist`); + if (!to) throw new ToolError(`Target node "${target}" does not exist`); + if (target === START_NODE_ID) throw new ToolError('Nothing can connect to the Start node'); + if (source === target) throw new ToolError('A node cannot connect to itself'); + if (NODE_DEFS[from.type]?.canHaveOutputs === false) { + throw new ToolError(`${NODE_DEFS[from.type].label} nodes (${from.type}) cannot have outgoing connections`); + } + if (edges.some((e) => e.source === source && e.target === target)) throw new ToolError('These nodes are already connected'); + if (source === START_NODE_ID && edges.some((e) => e.source === START_NODE_ID)) { + throw new ToolError('The Start node can only have one outgoing connection'); + } +} + +// ---------- validation report (does not block saving, like the editor) ---------- + +export function validateGraph(snapshot, dialogueId) { + const { nodes, edges } = loadGraph(snapshot, dialogueId); + const errors = []; + const warnings = []; + const ids = new Set(nodes.map((n) => n.id)); + const participantNames = new Set(snapshot.participants.map((p) => p.name)); + + if (!ids.has(START_NODE_ID)) errors.push('Start node is missing'); + for (const node of nodes) { + const def = NODE_DEFS[node.type]; + if (!def) { + errors.push(`${node.id}: unknown node type ${node.type}`); + continue; + } + for (const section of def.sections || []) { + for (const field of section.fields || []) { + if (!field.required) continue; + const value = node.data?.[field.id]; + if (value === undefined || value === null || (typeof value === 'string' && !value.trim())) { + errors.push(`${node.id} (${def.label} "${node.data?.displayName || ''}"): required field "${field.id}" is empty`); + } + } + } + if (node.data?.participant && !participantNames.has(node.data.participant)) { + warnings.push(`${node.id}: participant "${node.data.participant}" does not exist in this project`); + } + if (node.type === 'returnNode' && node.data?.targetNode && !ids.has(node.data.targetNode)) { + errors.push(`${node.id}: return target "${node.data.targetNode}" does not exist`); + } + if (node.type === 'openChildGraphNode' && node.data?.targetDialogue && !snapshot.dialogues.some((d) => d.id === node.data.targetDialogue)) { + errors.push(`${node.id}: child dialogue "${node.data.targetDialogue}" does not exist`); + } + if (node.type === 'answerNode' && !String(node.data?.selectionTitle || '').trim()) { + warnings.push(`${node.id}: player node has no selectionTitle (the clickable option label)`); + } + for (const dec of node.data?.decorators || []) { + if (!snapshot.decorators.some((d) => d.id === dec.id)) warnings.push(`${node.id}: decorator "${dec.name}" is no longer defined`); + } + } + for (const edge of edges) { + if (!ids.has(edge.source) || !ids.has(edge.target)) errors.push(`edge ${edge.id}: endpoint missing`); + } + const startOut = edges.filter((e) => e.source === START_NODE_ID).length; + if (startOut === 0) warnings.push('Start node has no outgoing connection'); + const seen = new Set([START_NODE_ID]); + const queue = [START_NODE_ID]; + while (queue.length) { + const id = queue.shift(); + for (const e of edges.filter((x) => x.source === id)) { + if (!seen.has(e.target)) { + seen.add(e.target); + queue.push(e.target); + } + } + const node = nodes.find((n) => n.id === id); + if (node?.type === 'returnNode' && node.data?.targetNode && !seen.has(node.data.targetNode)) { + seen.add(node.data.targetNode); + queue.push(node.data.targetNode); + } + } + for (const node of nodes) if (!seen.has(node.id)) warnings.push(`${node.id} ("${node.data?.displayName || node.type}") is not reachable from Start`); + return { valid: errors.length === 0, errors, warnings, nodeCount: nodes.length, edgeCount: edges.length }; +} + +// ---------- layout (simple layered layout, left to right is not used by the editor: top to bottom) ---------- + +export function autoLayout(nodes, edges, { xGap = 320, yGap = 180 } = {}) { + const children = new Map(); + for (const e of edges) { + if (!children.has(e.source)) children.set(e.source, []); + children.get(e.source).push(e.target); + } + const level = new Map([[START_NODE_ID, 0]]); + const queue = [START_NODE_ID]; + while (queue.length) { + const id = queue.shift(); + for (const t of children.get(id) || []) { + if (!level.has(t)) { + level.set(t, level.get(id) + 1); + queue.push(t); + } + } + } + let maxLevel = Math.max(0, ...level.values()); + for (const n of nodes) if (!level.has(n.id)) level.set(n.id, ++maxLevel); + const rows = new Map(); + for (const n of nodes) { + const l = level.get(n.id); + if (!rows.has(l)) rows.set(l, []); + rows.get(l).push(n); + } + for (const [l, list] of rows) { + list.forEach((n, i) => { + n.position = { x: (i - (list.length - 1) / 2) * xGap, y: l * yGap }; + }); + } +} diff --git a/mcp/lib/rules.mjs b/mcp/lib/rules.mjs new file mode 100644 index 0000000..c4270c7 --- /dev/null +++ b/mcp/lib/rules.mjs @@ -0,0 +1,118 @@ +// Naming and tree rules, ported from the stores/dialogs so the MCP enforces what the UI enforces. +import { NAME_PATTERN, toPascalName } from '../../src/helpers/nameHelpers.js'; +import { ToolError } from './store.mjs'; + +export const MAX_NAME_LENGTH = 16; // participants and categories +export const MAX_CATEGORY_DEPTH = 5; + +/** Same as the dialogs: spaces are joined into PascalCase, then the name must match the pattern. */ +export function cleanName(raw, label, { maxLength = 0 } = {}) { + const name = toPascalName(String(raw ?? '')); + if (!name) throw new ToolError(`${label} name is required`); + if (!NAME_PATTERN.test(name)) throw new ToolError(`${label} name may only contain letters and numbers`); + if (maxLength && name.length > maxLength) throw new ToolError(`${label} name must be ${maxLength} characters or fewer ("${name}")`); + return name; +} + +export function findByName(list, name, label) { + const wanted = String(name ?? '').trim().toLowerCase(); + const hit = list.find((item) => item.name.toLowerCase() === wanted); + if (!hit) throw new ToolError(`${label} "${name}" not found. Existing: ${list.map((i) => i.name).join(', ') || '(none)'}`); + return hit; +} + +export function rootCategoryId(categoryId, categories) { + let currentId = categoryId; + const seen = new Set(); + while (currentId) { + if (seen.has(currentId)) return null; + seen.add(currentId); + const current = categories.find((c) => c.id === currentId); + if (!current) return null; + if (!current.parentCategoryId) return current.id; + currentId = current.parentCategoryId; + } + return null; +} + +export function categoryDepth(categoryId, categories) { + let depth = 0; + let currentId = categoryId; + const seen = new Set(); + while (currentId) { + if (seen.has(currentId)) return Infinity; + seen.add(currentId); + const current = categories.find((c) => c.id === currentId); + if (!current) break; + depth += 1; + currentId = current.parentCategoryId || null; + } + return depth; +} + +export function subtreeDepth(categoryId, categories) { + const seen = new Set(); + const dfs = (id) => { + if (seen.has(id)) return 0; + seen.add(id); + const children = categories.filter((c) => c.parentCategoryId === id); + return 1 + children.reduce((max, child) => Math.max(max, dfs(child.id)), 0); + }; + return dfs(categoryId); +} + +export function treeIds(rootId, categories) { + const ids = new Set(); + const stack = [rootId]; + while (stack.length) { + const id = stack.pop(); + if (!id || ids.has(id)) continue; + ids.add(id); + categories.filter((c) => c.parentCategoryId === id).forEach((c) => stack.push(c.id)); + } + return ids; +} + +export function descendantIds(categoryId, categories) { + const ids = treeIds(categoryId, categories); + ids.delete(categoryId); + return ids; +} + +/** Throws if `name` already exists in the tree that contains (or will contain) the category. */ +export function assertUniqueCategoryName(name, parentCategoryId, categories, excludeId = null) { + const rootId = parentCategoryId ? rootCategoryId(parentCategoryId, categories) : null; + const clash = rootId + ? [...treeIds(rootId, categories)].some((id) => id !== excludeId && categories.find((c) => c.id === id)?.name === name) + : categories.some((c) => !c.parentCategoryId && c.id !== excludeId && c.name === name); + if (clash) throw new ToolError(`Category name "${name}" must be unique within its tree`); +} + +export function assertCategoryPlacement(categoryId, parentCategoryId, categories) { + if (!parentCategoryId) return; + if (!categories.some((c) => c.id === parentCategoryId)) throw new ToolError(`Parent category "${parentCategoryId}" not found`); + if (categoryId && (parentCategoryId === categoryId || descendantIds(categoryId, categories).has(parentCategoryId))) { + throw new ToolError('A category cannot be moved below itself'); + } + const parentDepth = categoryDepth(parentCategoryId, categories); + const own = categoryId ? subtreeDepth(categoryId, categories) : 1; + if (!Number.isFinite(parentDepth) || parentDepth + own > MAX_CATEGORY_DEPTH) { + throw new ToolError(`Category depth cannot exceed ${MAX_CATEGORY_DEPTH} levels`); + } +} + +const categoryOfParticipant = (participant, categories) => + categories.find((c) => c.id === participant.categoryId) || categories.find((c) => c.name === participant.category); + +/** Participant names are unique within a category tree (the tree of the participant's category). */ +export function assertUniqueParticipant(name, categoryName, participants, categories, excludeId = null) { + const category = categories.find((c) => c.name === categoryName); + const rootId = category ? rootCategoryId(category.id, categories) : null; + if (!rootId) return; + const clash = participants.some((p) => { + if (p.id === excludeId || p.name !== name) return false; + const other = categoryOfParticipant(p, categories); + return other && rootCategoryId(other.id, categories) === rootId; + }); + if (clash) throw new ToolError(`Participant name "${name}" must be unique within its category tree`); +} diff --git a/mcp/lib/store.mjs b/mcp/lib/store.mjs new file mode 100644 index 0000000..bc865e1 --- /dev/null +++ b/mcp/lib/store.mjs @@ -0,0 +1,161 @@ +// Project IO for the MCP server. Projects live as immutable revisions in the same file store the web app syncs with +// (DATA_DIR/sync/mountea-revision-v2--*.json, see src/lib/sync/core/revisionProtocol.js). The MCP reads a project's +// head revision, changes a copy, validates it with the web app's own canonical-project code and writes a new +// revision whose parent is the head it read. The browser picks it up on its next sync like any other writer. +import crypto from 'node:crypto'; +import { FileExistsError, createFile, listFiles, readFileText } from '../../server/storage.mjs'; +import { canonicalizeProject, hashProject, validateCanonicalProject } from '../../src/lib/persistence/canonicalProject.js'; + +export class ToolError extends Error {} + +export const uuid = () => crypto.randomUUID(); +export const nowIso = () => new Date().toISOString(); + +const REVISION_PREFIX = 'mountea-revision-v2--'; +const DEVICE_ID = 'mcp-server'; +const metaByFile = new Map(); // revision files are immutable, so their metadata is cached for the process lifetime +const locks = new Map(); // projectId -> promise chain (serialises writers inside this process) + +export function revisionFileName(projectId, revisionId) { + const digest = crypto.createHash('sha256').update(JSON.stringify([projectId, revisionId])).digest('hex'); + return `${REVISION_PREFIX}${digest}.json`; +} + +async function refreshIndex() { + const files = await listFiles({ prefix: REVISION_PREFIX }); + for (const { name } of files) { + if (metaByFile.has(name)) continue; + const text = await readFileText(name); + if (text === null) continue; + try { + const revision = JSON.parse(text)?.revision; + if (!revision?.id || !revision.projectId || !Array.isArray(revision.parentRevisionIds)) throw new Error('malformed revision'); + metaByFile.set(name, { + file: name, + id: revision.id, + projectId: revision.projectId, + parents: revision.parentRevisionIds, + operation: revision.operation, + createdAt: revision.createdAt, + projectName: revision.snapshot?.project?.name || '', + modifiedAt: revision.snapshot?.project?.modifiedAt || revision.createdAt, + }); + } catch (error) { + // Encrypted or foreign files are not ours to read; remember them so they are not parsed again. + metaByFile.set(name, null); + console.warn(`[mcp] skipping ${name}: ${error.message}`); + } + } + const byProject = new Map(); + for (const meta of metaByFile.values()) { + if (!meta) continue; + if (!byProject.has(meta.projectId)) byProject.set(meta.projectId, []); + byProject.get(meta.projectId).push(meta); + } + return byProject; +} + +function headsOf(revisions) { + const parentIds = new Set(revisions.flatMap((meta) => meta.parents)); + return revisions.filter((meta) => !parentIds.has(meta.id)); +} + +export async function listAllProjects() { + const projects = []; + for (const revisions of (await refreshIndex()).values()) { + const heads = headsOf(revisions); + const live = heads.filter((meta) => meta.operation !== 'delete'); + if (live.length === 0) continue; + const newest = live.reduce((a, b) => (a.createdAt >= b.createdAt ? a : b)); + projects.push({ + id: newest.projectId, + name: newest.projectName, + modifiedAt: newest.modifiedAt, + ...(live.length > 1 ? { conflict: 'divergent edits; resolve in the app' } : {}), + }); + } + return projects.sort((a, b) => a.name.localeCompare(b.name)); +} + +async function currentHead(projectId) { + const revisions = (await refreshIndex()).get(projectId); + if (!revisions) throw new ToolError(`Project "${projectId}" not found. Use list_projects.`); + const heads = headsOf(revisions); + if (heads.length > 1) { + throw new ToolError('This project has divergent edits (an unresolved sync conflict). Resolve the conflict in the app first.'); + } + if (heads[0].operation === 'delete') throw new ToolError(`Project "${projectId}" was deleted.`); + return heads[0]; +} + +export async function loadSnapshot(projectId) { + const head = await currentHead(projectId); + const revision = JSON.parse(await readFileText(head.file)).revision; + return revision.snapshot; +} + +const DRAFT_TOLERATED = new Set(['INVALID_START', 'UNRESOLVED_PARTICIPANT', 'UNRESOLVED_CATEGORY']); + +async function buildRevision(snapshot, { parents, operation, previous }) { + let canonical; + try { + canonical = await canonicalizeProject(snapshot); + } catch (error) { + throw new ToolError(`Invalid project data: ${error.message}`); + } + // Same rule as the app's draft commits: problems that already existed do not block, new ones do. + const known = new Set((previous ? validateCanonicalProject(previous, { localization: false }) : []).map((item) => `${item.code}:${item.path}`)); + const errors = validateCanonicalProject(canonical, { localization: false }).filter((item) => !DRAFT_TOLERATED.has(item.code) && !known.has(`${item.code}:${item.path}`)); + if (errors.length) throw new ToolError(`Invalid project data: ${errors[0].message} (${errors[0].path || errors[0].code})`); + return { + id: uuid(), + projectId: canonical.project.id, + parentRevisionIds: parents, + deviceId: DEVICE_ID, + payloadHash: await hashProject(canonical), + operation, + snapshot: canonical, + createdAt: nowIso(), + }; +} + +async function writeRevision(revision) { + const name = revisionFileName(revision.projectId, revision.id); + try { + await createFile(name, JSON.stringify({ schemaVersion: 2, revision })); + } catch (error) { + if (error instanceof FileExistsError) throw new ToolError('Revision identity collision, please retry.'); + throw error; + } + metaByFile.delete(name); // let the next index refresh read it back +} + +/** + * Read-modify-write one project. `fn(snapshot)` mutates a copy of the head snapshot and returns the tool result. + * If another writer added a revision in between, the operation is re-run on the new head (up to 3 times). + */ +export async function mutateProject(projectId, fn) { + const previous = locks.get(projectId) || Promise.resolve(); + const run = previous.catch(() => {}).then(async () => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const head = await currentHead(projectId); + const before = JSON.parse(await readFileText(head.file)).revision.snapshot; + const snapshot = structuredClone(before); + const result = await fn(snapshot); + snapshot.project.modifiedAt = nowIso(); + const revision = await buildRevision(snapshot, { parents: [head.id], operation: 'update', previous: before }); + const stillHead = headsOf((await refreshIndex()).get(projectId) || []); + if (stillHead.length !== 1 || stillHead[0].id !== head.id) continue; + await writeRevision(revision); + return result; + } + throw new ToolError('The project keeps changing underneath this request; try again.'); + }); + locks.set(projectId, run); + return run; +} + +/** Creates a brand new project (first revision, no parents). */ +export async function createProjectFile(snapshot) { + await writeRevision(await buildRevision(snapshot, { parents: [], operation: 'create', previous: null })); +} diff --git a/mcp/lib/zip.mjs b/mcp/lib/zip.mjs new file mode 100644 index 0000000..320d444 --- /dev/null +++ b/mcp/lib/zip.mjs @@ -0,0 +1,60 @@ +// Minimal ZIP writer (deflate) so the MCP server needs no dependencies. +import zlib from 'node:zlib'; + +function dosDateTime(date = new Date()) { + const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const day = ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { time, day }; +} + +/** files: [{ name: 'folder/file.json', data: Buffer | string }] -> Buffer */ +export function buildZip(files) { + const { time, day } = dosDateTime(); + const locals = []; + const centrals = []; + let offset = 0; + for (const file of files) { + const name = Buffer.from(file.name, 'utf8'); + const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data), 'utf8'); + const packed = zlib.deflateRawSync(raw); + const crc = zlib.crc32(raw); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); // UTF-8 names + local.writeUInt16LE(8, 8); + local.writeUInt16LE(time, 10); + local.writeUInt16LE(day, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(packed.length, 18); + local.writeUInt32LE(raw.length, 22); + local.writeUInt16LE(name.length, 26); + locals.push(local, name, packed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(time, 12); + central.writeUInt16LE(day, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(packed.length, 20); + central.writeUInt32LE(raw.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt32LE(offset, 42); + centrals.push(central, name); + + offset += local.length + name.length + packed.length; + } + const centralSize = centrals.reduce((n, b) => n + b.length, 0); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, ...centrals, end]); +} diff --git a/mcp/mcp.test.js b/mcp/mcp.test.js new file mode 100644 index 0000000..adc3506 --- /dev/null +++ b/mcp/mcp.test.js @@ -0,0 +1,307 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import JSZip from 'jszip'; + +const TOKEN = 'test-token-0123456789abcdef0123456789'; +let proc; +let base; +let dataDir; +let rpcId = 1; + +// Every change is an immutable revision file; the head is the revision nobody names as parent. +function revisionsOf(projectId) { + const dir = path.join(dataDir, 'sync'); + return fs.readdirSync(dir).map((name) => JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')).revision).filter((revision) => revision.projectId === projectId); +} +function headOf(projectId) { + const all = revisionsOf(projectId); + const parents = new Set(all.flatMap((revision) => revision.parentRevisionIds)); + const heads = all.filter((revision) => !parents.has(revision.id)); + expect(heads).toHaveLength(1); + return heads[0]; +} + +async function rpc(method, params, { auth = true, url = '/mcp' } = {}) { + const res = await fetch(`${base}${url}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: `Bearer ${TOKEN}` } : {}) }, + body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method, params }), + }); + return { status: res.status, body: res.status === 200 ? await res.json() : null }; +} + +async function tool(name, args = {}) { + const { body } = await rpc('tools/call', { name, arguments: args }); + const text = body.result.content[0].text; + if (body.result.isError) return { error: text }; + return JSON.parse(text); +} + +beforeAll(async () => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-test-')); + const port = 18000 + Math.floor(Math.random() * 1000); + base = `http://127.0.0.1:${port}`; + proc = spawn('node', ['--import', './mcp/register.mjs', 'mcp/server.mjs'], { env: { ...process.env, MCP_TOKEN: TOKEN, MCP_PORT: String(port), MCP_HOST: '127.0.0.1', DATA_DIR: dataDir }, stdio: 'pipe' }); + for (let i = 0; i < 50; i += 1) { + try { + if ((await fetch(`${base}/health`)).ok) return; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('MCP server did not start'); +}); + +afterAll(() => { + proc?.kill(); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('auth and protocol', () => { + it('rejects missing or wrong tokens', async () => { + expect((await rpc('tools/list', {}, { auth: false })).status).toBe(401); + expect((await rpc('tools/list', {}, { auth: false, url: '/mcp/wrong' })).status).toBe(401); + }); + it('accepts the bearer header and the token in the path', async () => { + expect((await rpc('tools/list', {})).status).toBe(200); + expect((await rpc('tools/list', {}, { auth: false, url: `/mcp/${TOKEN}` })).status).toBe(200); + }); + it('initializes and answers notifications with 202', async () => { + const { body } = await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } }); + expect(body.result.serverInfo.name).toBe('mountea-dialoguer'); + const res = await fetch(`${base}/mcp`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) }); + expect(res.status).toBe(202); + }); + it('answers an oversized body with 413 and keeps serving', async () => { + const res = await fetch(`${base}/mcp`, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: { pad: 'x'.repeat(11 * 1024 * 1024) } }) }); + expect(res.status).toBe(413); + expect((await rpc('ping', {})).status).toBe(200); + }); + it('has no tool that deletes projects or dialogues', async () => { + const { body } = await rpc('tools/list', {}); + const names = body.result.tools.map((t) => t.name); + expect(names.length).toBeGreaterThan(30); + expect(names.filter((n) => /delete_(project|dialogue)|remove_(project|dialogue)/.test(n))).toEqual([]); + }); +}); + +describe('authoring a dialogue', () => { + let projectId; + let dialogueId; + + it('creates project, categories and participants with the UI rules', async () => { + const project = await tool('create_project', { name: 'my test project', defaultLocale: 'en' }); + projectId = project.projectId; + expect(project.name).toBe('MyTestProject'); + + expect((await tool('create_category', { projectId, name: 'Characters' })).name).toBe('Characters'); + expect((await tool('create_category', { projectId, name: 'Townsfolk', parent: 'Characters' })).parent).toBe('Characters'); + expect((await tool('create_category', { projectId, name: 'ThisNameIsWayTooLongForACategory' })).error).toMatch(/16/); + expect((await tool('create_category', { projectId, name: 'Townsfolk', parent: 'Characters' })).error).toMatch(/unique/); + + expect((await tool('create_participant', { projectId, name: 'Old Bob', category: 'Townsfolk' })).name).toBe('OldBob'); + expect((await tool('create_participant', { projectId, name: 'OldBob', category: 'Characters' })).error).toMatch(/unique/); + expect((await tool('create_participant', { projectId, name: 'X', category: 'Nope' })).error).toMatch(/not found/); + }); + + it('enforces category depth', async () => { + let parent = 'Characters'; + for (const name of ['L2', 'L3', 'L4']) { + await tool('create_category', { projectId, name, parent }); + parent = name; + } + // Characters > Townsfolk is level 2; Characters > L2 > L3 > L4 is level 4, one more is 5, six is too deep. + expect((await tool('create_category', { projectId, name: 'L5', parent })).error).toBeUndefined(); + expect((await tool('create_category', { projectId, name: 'L6', parent: 'L5' })).error).toMatch(/depth/); + }); + + it('creates a dialogue and builds a graph in one call', async () => { + const dlg = await tool('create_dialogue', { projectId, name: 'Greeting' }); + dialogueId = dlg.dialogueId; + expect((await tool('create_dialogue', { projectId, name: 'greeting' })).error).toMatch(/already exists/); + + const graph = await tool('set_graph', { + projectId, + dialogueId, + mode: 'replace', + nodes: [ + { ref: 'hello', type: 'leadNode', displayName: 'Hello', participant: 'OldBob', rows: ['Well met, traveller.', { text: 'What brings you here?', duration: 4 }] }, + { ref: 'asky', type: 'answerNode', displayName: 'AskCurse', selectionTitle: 'Ask about the curse', participant: 'OldBob', rows: ['Tell me about the curse.'] }, + { ref: 'bye', type: 'answerNode', displayName: 'Leave', selectionTitle: 'Say goodbye', participant: 'OldBob', rows: ['I must go.'] }, + { ref: 'curse', type: 'leadNode', displayName: 'CurseInfo', participant: 'OldBob', rows: ['The curse began long ago.'] }, + { ref: 'back', type: 'returnNode', displayName: 'Back', targetNode: 'hello' }, + { ref: 'end', type: 'completeNode', displayName: 'End', participant: 'OldBob' }, + ], + edges: [ + { source: 'start', target: 'hello' }, + { source: 'hello', target: 'asky' }, + { source: 'hello', target: 'bye' }, + { source: 'asky', target: 'curse' }, + { source: 'curse', target: 'back' }, + { source: 'bye', target: 'end' }, + ], + }); + expect(graph.error).toBeUndefined(); + expect(graph.nodeCount).toBe(7); + expect(graph.errors).toEqual([]); + }); + + it('reads the graph back with text and structure', async () => { + const g = await tool('get_dialogue', { projectId, dialogueId }); + const hello = g.nodes.find((n) => n.displayName === 'Hello'); + expect(hello.rows.map((r) => r.text)).toEqual(['Well met, traveller.', 'What brings you here?']); + expect(hello.rows[1].duration).toBe(4); + expect(g.nodes.find((n) => n.displayName === 'AskCurse').selectionTitle).toBe('Ask about the curse'); + expect(g.edges).toHaveLength(6); + const back = g.nodes.find((n) => n.type === 'returnNode'); + expect(back.targetNode).toBe(hello.id); + }); + + it('stores text as localized strings, not inline', async () => { + const head = headOf(projectId).snapshot; + const stored = head.nodes.find((n) => n.data.displayNameKey && n.type === 'leadNode'); + expect(stored.data.displayName).toBeUndefined(); + expect(head.localizedStrings.some((e) => e.values.en === 'Well met, traveller.')).toBe(true); + expect(head.nodes.some((n) => n.id === '00000000-0000-0000-0000-000000000001')).toBe(true); + // Identities, not just names: the app resolves speakers and categories by id. + const bob = head.participants.find((p) => p.name === 'OldBob'); + expect(bob.categoryId).toBe(head.categories.find((c) => c.name === 'Townsfolk').id); + expect(head.nodes.find((n) => n.data.participant === 'OldBob').data.participantId).toBe(bob.id); + }); + + it('rejects invalid structure', async () => { + const g = await tool('get_dialogue', { projectId, dialogueId }); + const end = g.nodes.find((n) => n.type === 'completeNode').id; + const hello = g.nodes.find((n) => n.displayName === 'Hello').id; + expect((await tool('connect_nodes', { projectId, dialogueId, source: end, target: hello })).error).toMatch(/cannot have outgoing/); + expect((await tool('connect_nodes', { projectId, dialogueId, source: 'start', target: hello })).error).toMatch(/already connected|only have one/); + expect((await tool('delete_node', { projectId, dialogueId, nodeId: '00000000-0000-0000-0000-000000000001' })).error).toMatch(/Start/); + expect((await tool('add_node', { projectId, dialogueId, type: 'leadNode', participant: 'Ghost' })).error).toMatch(/does not exist/); + }); + + it('translates text and reports missing translations', async () => { + expect((await tool('set_text', { projectId, dialogueId, locale: 'de', items: [] })).error).toMatch(/not enabled/); + await tool('update_project', { projectId, addLocales: ['de'] }); + const missing = await tool('list_missing_translations', { projectId, locale: 'de' }); + expect(missing.count).toBeGreaterThan(5); + const g = await tool('get_dialogue', { projectId, dialogueId }); + const hello = g.nodes.find((n) => n.displayName === 'Hello'); + await tool('set_text', { projectId, dialogueId, locale: 'de', items: [{ nodeId: hello.id, field: 'rowText', rowIndex: 0, text: 'Sei gegrüßt, Reisender.' }] }); + const de = await tool('get_dialogue', { projectId, dialogueId, locale: 'de' }); + expect(de.nodes.find((n) => n.id === hello.id).rows[0].text).toBe('Sei gegrüßt, Reisender.'); + expect((await tool('list_missing_translations', { projectId, locale: 'de' })).count).toBe(missing.count - 1); + // The default locale is untouched. + const en = await tool('get_dialogue', { projectId, dialogueId }); + expect(en.nodes.find((n) => n.id === hello.id).rows[0].text).toBe('Well met, traveller.'); + }); + + it('keeps node data across later edits and renames participants everywhere', async () => { + await tool('update_participant', { projectId, participant: 'OldBob', name: 'Bob' }); + const g = await tool('get_dialogue', { projectId, dialogueId }); + expect(g.nodes.filter((n) => n.participant === 'Bob').length).toBe(5); + const hello = g.nodes.find((n) => n.displayName === 'Hello'); + expect(hello.rows).toHaveLength(2); + expect((await tool('delete_participant', { projectId, participant: 'Bob' })).error).toMatch(/used by/); + }); + + it('supports decorators and conditions', async () => { + await tool('create_decorator', { projectId, name: 'Mood', properties: [{ name: 'level', type: 'int', defaultValue: 1 }] }); + await tool('create_condition', { projectId, name: 'HasKey', properties: [{ name: 'item', type: 'string', defaultValue: 'Key' }] }); + const g = await tool('get_dialogue', { projectId, dialogueId }); + const hello = g.nodes.find((n) => n.displayName === 'Hello').id; + const curse = g.nodes.find((n) => n.displayName === 'CurseInfo').id; + expect((await tool('update_node', { projectId, dialogueId, nodeId: hello, decorators: [{ name: 'Mood', values: { level: 3 } }] })).error).toBeUndefined(); + expect((await tool('update_node', { projectId, dialogueId, nodeId: hello, decorators: ['Nope'] })).error).toMatch(/not defined/); + const asky = g.nodes.find((n) => n.displayName === 'AskCurse').id; + expect((await tool('update_edge', { projectId, dialogueId, source: asky, target: curse, conditions: { mode: 'all', rules: [{ name: 'HasKey', values: { item: 'Gold' } }] } })).rules).toBe(1); + expect((await tool('delete_decorator', { projectId, decorator: 'Mood' })).error).toMatch(/used/); + }); + + it('cascade-deletes a branch and cleans return targets', async () => { + const g = await tool('get_dialogue', { projectId, dialogueId }); + const asky = g.nodes.find((n) => n.displayName === 'AskCurse').id; + const res = await tool('delete_node', { projectId, dialogueId, nodeId: asky, cascade: true }); + expect(res.deleted).toHaveLength(3); // answer, curse info, return + const after = await tool('get_dialogue', { projectId, dialogueId }); + expect(after.nodes.some((n) => n.type === 'returnNode')).toBe(false); + }); + + it('clears the content of a dialogue but keeps the dialogue', async () => { + const copy = await tool('create_dialogue', { projectId, name: 'ToClear' }); + await tool('add_node', { projectId, dialogueId: copy.dialogueId, type: 'completeNode', displayName: 'End', participant: 'Bob', connectFrom: 'start' }); + const res = await tool('clear_dialogue', { projectId, dialogueId: copy.dialogueId }); + expect(res).toEqual({ removedNodes: 1, removedEdges: 1 }); + const g = await tool('get_dialogue', { projectId, dialogueId: copy.dialogueId }); + expect(g.nodes.map((n) => n.type)).toEqual(['startNode']); + expect((await tool('list_dialogues', { projectId })).dialogues.some((d) => d.id === copy.dialogueId)).toBe(true); + }); + + it('validates a dialogue', async () => { + const report = await tool('validate_dialogue', { projectId, dialogueId }); + expect(report.valid).toBe(true); + }); + + it('exports a dialogue as a v3 archive like the Export button', async () => { + const res = await tool('export_dialogue', { projectId, dialogueId, inline: true }); + const zip = await JSZip.loadAsync(Buffer.from(res.base64, 'base64')); + const manifest = JSON.parse(await zip.file('manifest.json').async('string')); + expect(manifest.format).toBe('mountea.archive.v3'); + expect(manifest.kind).toBe('dialogue'); + expect(manifest.dialogueId).toBe(dialogueId); + const tables = new Set(manifest.records.map((r) => r.table)); + for (const table of ['dialogues', 'participants', 'categories', 'nodes', 'edges', 'localizedStrings']) expect(tables.has(table)).toBe(true); + const strings = []; + for (const record of manifest.records.filter((r) => r.table === 'localizedStrings')) strings.push(JSON.parse(await zip.file(record.path).async('string'))); + const values = (field) => strings.filter((e) => e.field === field).map((e) => e.values.en); + // Display name and selection title stay separate entries (the plugin reads selectionTitleKey). + expect(values('selectionTitle')).toContain('Say goodbye'); + expect(values('displayName')).toContain('Leave'); + expect(values('displayName')).not.toContain('Say goodbye'); + + const project = await tool('export_project', { projectId, inline: true }); + const projectZip = await JSZip.loadAsync(Buffer.from(project.base64, 'base64')); + expect(JSON.parse(await projectZip.file('manifest.json').async('string')).kind).toBe('project'); + + const link = await tool('export_dialogue', { projectId, dialogueId }); + expect(link.downloadUrl).toContain(`/export/${TOKEN}/`); + const download = await fetch(link.downloadUrl.replace(/^https?:\/\/[^/]+/, base)); + expect(download.status).toBe(200); + expect(Buffer.from(await download.arrayBuffer()).subarray(0, 2).toString()).toBe('PK'); + expect((await fetch(`${base}/export/wrong/${link.fileName}`)).status).toBe(401); + }); + + it('supports row timing and rejects invalid values', async () => { + const g = await tool('get_dialogue', { projectId, dialogueId }); + const hello = g.nodes.find((n) => n.displayName === 'Hello'); + const ok = await tool('update_node', { projectId, dialogueId, nodeId: hello.id, rows: [{ text: 'Click to continue', executionBehaviour: 'awaitInput', durationMode: 'override', durationOverride: 2.5 }, 'plain'] }); + expect(ok.error).toBeUndefined(); + const row = headOf(projectId).snapshot.nodes.find((n) => n.id === hello.id).data.dialogueRows[0]; + expect(row).toMatchObject({ executionBehaviour: 'awaitInput', durationMode: 'override', durationOverride: 2.5 }); + const plain = headOf(projectId).snapshot.nodes.find((n) => n.id === hello.id).data.dialogueRows[1]; + expect(plain.executionBehaviour).toBeUndefined(); // defaults are not stored + expect((await tool('update_node', { projectId, dialogueId, nodeId: hello.id, rows: [{ text: 'x', durationMode: 'sideways' }] })).error).toMatch(/row timing/i); + expect((await tool('update_node', { projectId, dialogueId, nodeId: hello.id, rows: [{ text: 'x', durationOverride: -1 }] })).error).toMatch(/row timing/i); + // Put the original lines back for the tests that follow. + await tool('update_node', { projectId, dialogueId, nodeId: hello.id, rows: ['Well met, traveller.', { text: 'What brings you here?', duration: 4 }] }); + }); + + it('keeps every change as an immutable revision with a linear history', async () => { + const all = revisionsOf(projectId); + expect(all.length).toBeGreaterThan(10); + const byId = new Map(all.map((r) => [r.id, r])); + let cursor = headOf(projectId); + let steps = 1; + while (cursor.parentRevisionIds.length) { + expect(cursor.parentRevisionIds).toHaveLength(1); + cursor = byId.get(cursor.parentRevisionIds[0]); + expect(cursor).toBeTruthy(); + steps += 1; + } + expect(steps).toBe(all.length); + expect(cursor.operation).toBe('create'); + }); +}); diff --git a/mcp/register.mjs b/mcp/register.mjs new file mode 100644 index 0000000..a382235 --- /dev/null +++ b/mcp/register.mjs @@ -0,0 +1,6 @@ +// Run with: node --import ./mcp/register.mjs mcp/server.mjs +// Lets Node load the web app's pure modules (src/lib/...) unchanged: "@/..." aliases, extension-less imports +// and JSON imports are resolved the way Vite does it. +import { register } from 'node:module'; + +register('./alias-hooks.mjs', import.meta.url); diff --git a/mcp/server.mjs b/mcp/server.mjs new file mode 100644 index 0000000..b4ccec2 --- /dev/null +++ b/mcp/server.mjs @@ -0,0 +1,196 @@ +// Mountea Dialoguer MCP server (Streamable HTTP, stateless, no dependencies). +// Lets Claude read and edit project data directly. Runs as its own process on its own port so it can live on a +// separate hostname outside Cloudflare Access. It has its own auth: MCP_TOKEN. +// Authorization: Bearer or POST /mcp/ (for clients that cannot send headers) +import http from 'node:http'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { DATA_DIR, ensureDirs } from '../server/storage.mjs'; +import { TOOLS, TOOL_MAP } from './tools/index.mjs'; +import { validateArgs } from './tools/util.mjs'; +import { ToolError } from './lib/store.mjs'; +import { EXPORT_DIR } from './tools/export.mjs'; + +const PORT = Number(process.env.MCP_PORT || 8081); +const HOST = process.env.MCP_HOST || '0.0.0.0'; +const TOKEN = process.env.MCP_TOKEN || ''; +const MAX_BODY = Number(process.env.MCP_MAX_BODY_MB || 10) * 1024 * 1024; +const RATE_PER_MIN = Number(process.env.MCP_RATE_PER_MIN || 240); +const AUTH_FAIL_PER_MIN = 20; +const PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05']; +const AUDIT_LOG = path.join(DATA_DIR, 'mcp-audit.log'); + +if (TOKEN.length < 24) { + console.error('MCP_TOKEN must be set and at least 24 characters long. Example: openssl rand -hex 24'); + process.exit(1); +} + +const digest = (value) => crypto.createHash('sha256').update(value).digest(); +const TOKEN_DIGEST = digest(TOKEN); +const tokenMatches = (candidate) => crypto.timingSafeEqual(digest(String(candidate || '')), TOKEN_DIGEST); + +const hits = new Map(); // ip -> { count, reset } +function limited(ip, max, bucket) { + const key = `${bucket}:${ip}`; + const now = Date.now(); + const entry = hits.get(key); + if (!entry || entry.reset < now) { + hits.set(key, { count: 1, reset: now + 60000 }); + return false; + } + entry.count += 1; + return entry.count > max; +} +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of hits) if (entry.reset < now) hits.delete(key); +}, 60000).unref(); + +const clientIp = (req) => String(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(); + +function send(res, status, body, headers = {}) { + const payload = body === undefined ? '' : JSON.stringify(body); + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }); + res.end(payload); +} + +async function readBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY) throw Object.assign(new Error('Payload too large'), { status: 413 }); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} + +const redact = (value) => JSON.stringify(value, (key, v) => (typeof v === 'string' && v.length > 200 ? `${v.slice(0, 80)}…(${v.length} chars)` : v)); + +async function audit(entry) { + try { + await fs.appendFile(AUDIT_LOG, `${JSON.stringify(entry)}\n`); + } catch { + /* the log is best effort */ + } +} + +async function callTool(params, ip, origin) { + const tool = TOOL_MAP.get(params?.name); + if (!tool) return { isError: true, content: [{ type: 'text', text: `Unknown tool "${params?.name}"` }] }; + const started = Date.now(); + try { + const args = validateArgs(tool, params.arguments); + const result = await tool.handler(args); + if (result?.downloadPath) { + result.downloadUrl = `${origin}/export/${encodeURIComponent(TOKEN)}${result.downloadPath.slice('/export'.length)}`; + delete result.downloadPath; + } + await audit({ ts: new Date().toISOString(), ip, tool: tool.name, args: redact(args), ok: true, ms: Date.now() - started }); + return { content: [{ type: 'text', text: JSON.stringify(result ?? { ok: true }, null, 2) }] }; + } catch (error) { + const expected = error instanceof ToolError; + if (!expected) console.error('[mcp] tool failed', tool.name, error); + await audit({ ts: new Date().toISOString(), ip, tool: tool.name, args: redact(params.arguments || {}), ok: false, error: String(error.message).slice(0, 300), ms: Date.now() - started }); + return { isError: true, content: [{ type: 'text', text: expected ? error.message : `Internal error: ${error.message}` }] }; + } +} + +async function handleMessage(message, ip, origin) { + const { id, method, params } = message || {}; + const isNotification = id === undefined || id === null; + const reply = (result) => (isNotification ? null : { jsonrpc: '2.0', id, result }); + const fail = (code, text) => (isNotification ? null : { jsonrpc: '2.0', id, error: { code, message: text } }); + + switch (method) { + case 'initialize': { + const wanted = params?.protocolVersion; + return reply({ + protocolVersion: PROTOCOLS.includes(wanted) ? wanted : PROTOCOLS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'mountea-dialoguer', version: '1.0.0' }, + instructions: + 'Edits Mountea Dialoguer projects. Workflow: list_projects -> get_project -> create participants/categories -> create_dialogue -> set_graph -> validate_dialogue. Projects and dialogues cannot be deleted here. Open browser tabs refresh automatically (or ask the user to reload).', + }); + } + case 'ping': + return reply({}); + case 'tools/list': + return reply({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }); + case 'tools/call': + return reply(await callTool(params, ip, origin)); + default: + if (typeof method === 'string' && method.startsWith('notifications/')) return null; + return fail(-32601, `Method not found: ${method}`); + } +} + +async function handle(req, res) { + const url = new URL(req.url, 'http://localhost'); + const ip = clientIp(req); + const proto = String(req.headers['x-forwarded-proto'] || 'http').split(',')[0].trim(); + const origin = `${proto}://${req.headers.host}`; + + if (url.pathname === '/health') return send(res, 200, { ok: true }); + + const download = url.pathname.match(/^\/export\/([^/]+)\/([A-Za-z0-9_.-]+\.mnteadlg(?:proj)?)$/); + if (download && req.method === 'GET') { + if (!tokenMatches(decodeURIComponent(download[1]))) { + if (limited(ip, AUTH_FAIL_PER_MIN, 'authfail')) return send(res, 429, { error: 'Too many failed attempts' }); + return send(res, 401, { error: 'Unauthorized' }); + } + try { + const file = await fs.readFile(path.join(EXPORT_DIR, download[2])); + res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${download[2]}"`, 'Cache-Control': 'no-store' }); + return res.end(file); + } catch { + return send(res, 404, { error: 'Not found or expired' }); + } + } + + const match = url.pathname.match(/^\/mcp(?:\/([^/]+))?\/?$/); + if (!match) return send(res, 404, { error: 'Not found' }); + + const header = String(req.headers.authorization || ''); + const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : ''; + const authorised = tokenMatches(bearer) || (match[1] !== undefined && tokenMatches(decodeURIComponent(match[1]))); + if (!authorised) { + if (limited(ip, AUTH_FAIL_PER_MIN, 'authfail')) return send(res, 429, { error: 'Too many failed attempts' }, { 'Retry-After': '60' }); + return send(res, 401, { error: 'Unauthorized' }, { 'WWW-Authenticate': 'Bearer' }); + } + if (limited(ip, RATE_PER_MIN, 'req')) return send(res, 429, { error: 'Rate limit' }, { 'Retry-After': '60' }); + + if (req.method !== 'POST') return send(res, 405, { error: 'Use POST' }, { Allow: 'POST' }); + + let body; + try { + body = JSON.parse(await readBody(req)); + } catch (error) { + if (error.status === 413) { + req.unpipe?.(); + req.resume(); + return send(res, 413, { error: 'Payload too large' }, { Connection: 'close' }); + } + return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); + } + + if (Array.isArray(body)) { + const results = (await Promise.all(body.map((m) => handleMessage(m, ip, origin)))).filter(Boolean); + return results.length ? send(res, 200, results) : send(res, 202); + } + const result = await handleMessage(body, ip, origin); + return result ? send(res, 200, result) : send(res, 202); +} + +await ensureDirs(); +http + .createServer(async (req, res) => { + try { + await handle(req, res); + } catch (error) { + console.error('[mcp]', error); + if (!res.headersSent) send(res, 500, { error: 'Server error' }); + } + }) + .listen(PORT, HOST, () => console.log(`Mountea Dialoguer MCP on ${HOST}:${PORT} data=${DATA_DIR} tools=${TOOLS.length}`)); diff --git a/mcp/tools/defs.mjs b/mcp/tools/defs.mjs new file mode 100644 index 0000000..05c8c46 --- /dev/null +++ b/mcp/tools/defs.mjs @@ -0,0 +1,113 @@ +import { loadSnapshot, mutateProject, nowIso, ToolError, uuid } from '../lib/store.mjs'; +import { cleanName } from '../lib/rules.mjs'; +import { normalizePropertyDefault, normalizePropertyType } from '../../src/lib/domainIntegrity.js'; +import { defineTool, str, bool, arr, PROJECT } from './util.mjs'; + +// The Unreal importer only supports string, number and boolean. int/float/bool are accepted as aliases. +const PROPERTY_TYPES = ['string', 'number', 'boolean', 'int', 'float', 'bool']; +const PROPERTIES = arr('Properties: [{name, type: string|number|boolean, defaultValue}]', { + type: 'object', + properties: { name: { type: 'string' }, type: { type: 'string', enum: PROPERTY_TYPES }, defaultValue: {} }, + required: ['name', 'type'], +}); + +function cleanProperties(list = []) { + const seen = new Set(); + return list.map((p) => { + const name = String(p.name || '').trim(); + if (!name) throw new ToolError('Every property needs a name'); + if (seen.has(name.toLowerCase())) throw new ToolError(`Duplicate property "${name}"`); + seen.add(name.toLowerCase()); + if (!PROPERTY_TYPES.includes(p.type)) throw new ToolError(`Property "${name}": type must be one of ${PROPERTY_TYPES.join(', ')}`); + const type = normalizePropertyType(p.type); + const defaultValue = normalizePropertyDefault(type, p.defaultValue === undefined ? (type === 'string' ? '' : type === 'number' ? 0 : false) : p.defaultValue); + if (typeof defaultValue !== type) throw new ToolError(`Property "${name}": default must be a ${type}`); + return { name, type, defaultValue }; + }); +} + +/** Decorators and conditions are the same kind of definition; they differ in where their instances live. */ +function makeDefTools({ kind, plural, collection, usage, rename }) { + const Cap = kind[0].toUpperCase() + kind.slice(1); + const find = (s, ref) => { + const hit = s[collection].find((d) => d.id === ref || d.name.toLowerCase() === String(ref).toLowerCase()); + if (!hit) throw new ToolError(`${Cap} "${ref}" not found. Existing: ${s[collection].map((d) => d.name).join(', ') || '(none)'}`); + return hit; + }; + return [ + defineTool(`list_${plural}`, `List ${kind} definitions with their properties.`, { projectId: PROJECT }, ['projectId'], async ({ projectId }) => { + const s = await loadSnapshot(projectId); + return { [plural]: s[collection].map((d) => ({ id: d.id, name: d.name, type: d.type, properties: d.properties, usedBy: usage(s, d) })) }; + }), + defineTool( + `create_${kind}`, + `Create a ${kind} definition. Name: PascalCase, letters/numbers/_/-. Properties have a type (string, number or boolean) and a default.`, + { projectId: PROJECT, name: str(`${Cap} name`), type: str('Optional free-text type/category'), properties: PROPERTIES }, + ['projectId', 'name'], + (args) => + mutateProject(args.projectId, (s) => { + const name = cleanName(args.name, Cap); + if (s[collection].some((d) => d.name.toLowerCase() === name.toLowerCase())) throw new ToolError(`A ${kind} named "${name}" already exists`); + const now = nowIso(); + const def = { id: uuid(), projectId: args.projectId, name, type: args.type || '', properties: cleanProperties(args.properties), createdAt: now, modifiedAt: now }; + s[collection].push(def); + return { [`${kind}Id`]: def.id, name }; + }) + ), + defineTool( + `update_${kind}`, + `Change a ${kind} definition (rename, type, properties). A rename updates existing instances; changed properties do not migrate existing values.`, + { projectId: PROJECT, [kind]: str(`${Cap} name or id`), name: str('New name'), type: str('Type'), properties: PROPERTIES }, + ['projectId', kind], + (args) => + mutateProject(args.projectId, (s) => { + const def = find(s, args[kind]); + if (args.name !== undefined) { + const name = cleanName(args.name, Cap); + if (s[collection].some((d) => d.id !== def.id && d.name.toLowerCase() === name.toLowerCase())) throw new ToolError(`A ${kind} named "${name}" already exists`); + rename(s, def, name); + def.name = name; + } + if (args.type !== undefined) def.type = args.type; + if (args.properties !== undefined) def.properties = cleanProperties(args.properties); + def.modifiedAt = nowIso(); + return { name: def.name, properties: def.properties }; + }) + ), + defineTool( + `delete_${kind}`, + `Delete a ${kind} definition. Refused while it is used, unless force=true (instances stay as stale copies).`, + { projectId: PROJECT, [kind]: str(`${Cap} name or id`), force: bool('Delete even if used') }, + ['projectId', kind], + (args) => + mutateProject(args.projectId, (s) => { + const def = find(s, args[kind]); + const used = usage(s, def); + if (used && !args.force) throw new ToolError(`${Cap} "${def.name}" is used ${used} time(s). Remove those uses or pass force=true.`); + s[collection] = s[collection].filter((d) => d.id !== def.id); + return { deleted: def.name, stillReferenced: used }; + }) + ), + ]; +} + +export const defTools = [ + ...makeDefTools({ + kind: 'decorator', + plural: 'decorators', + collection: 'decorators', + usage: (s, def) => s.nodes.reduce((n, node) => n + (node.data?.decorators || []).filter((i) => i.id === def.id).length, 0), + rename: (s, def, name) => { + for (const node of s.nodes) for (const i of node.data?.decorators || []) if (i.id === def.id) i.name = name; + }, + }), + ...makeDefTools({ + kind: 'condition', + plural: 'conditions', + collection: 'conditions', + usage: (s, def) => s.edges.reduce((n, edge) => n + (edge.data?.conditions?.rules || []).filter((r) => r.id === def.id).length, 0), + rename: (s, def, name) => { + for (const edge of s.edges) for (const r of edge.data?.conditions?.rules || []) if (r.id === def.id) r.name = name; + }, + }), +]; diff --git a/mcp/tools/export.mjs b/mcp/tools/export.mjs new file mode 100644 index 0000000..929cbaf --- /dev/null +++ b/mcp/tools/export.mjs @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { DATA_DIR } from '../../server/storage.mjs'; +import { loadSnapshot, ToolError } from '../lib/store.mjs'; +import { buildDialogueExport, buildProjectExport } from '../lib/export.mjs'; +import { getDialogue } from '../lib/graph.mjs'; +import { defineTool, bool, PROJECT, DIALOGUE } from './util.mjs'; + +export const EXPORT_DIR = path.join(DATA_DIR, 'exports'); +const KEEP_MS = 24 * 60 * 60 * 1000; +const INLINE_LIMIT = 1024 * 1024; + +async function pruneOldExports() { + const files = await fs.readdir(EXPORT_DIR).catch(() => []); + for (const f of files) { + const stat = await fs.stat(path.join(EXPORT_DIR, f)).catch(() => null); + if (stat && Date.now() - stat.mtimeMs > KEEP_MS) await fs.unlink(path.join(EXPORT_DIR, f)).catch(() => {}); + } +} + +export const exportTools = [ + defineTool( + 'export_dialogue', + 'Export a dialogue as a .mnteadlg archive (format mountea.archive.v3, the file the Unreal plugin imports; same content as the Export button; includes child dialogues it opens). Returns a download link valid for 24 hours, or the file as base64 when inline=true and it is under 1 MB.', + { projectId: PROJECT, dialogueId: DIALOGUE, inline: bool('Return base64 instead of a link (small files only)') }, + ['projectId', 'dialogueId'], + async ({ projectId, dialogueId, inline }) => { + const snapshot = await loadSnapshot(projectId); + const dialogue = getDialogue(snapshot, dialogueId); + const zip = await buildDialogueExport(snapshot, dialogueId); + if (inline) { + if (zip.length > INLINE_LIMIT) throw new ToolError(`Export is ${zip.length} bytes; use a download link instead (inline=false)`); + return { fileName: `${dialogue.name}.mnteadlg`, sizeBytes: zip.length, base64: zip.toString('base64') }; + } + await fs.mkdir(EXPORT_DIR, { recursive: true }); + await pruneOldExports(); + const fileName = `${dialogue.name}-${new Date().toISOString().replace(/[:.]/g, '-')}.mnteadlg`; + await fs.writeFile(path.join(EXPORT_DIR, fileName), zip); + return { fileName, sizeBytes: zip.length, downloadPath: `/export/${fileName}` }; + } + ), + + defineTool( + 'export_project', + 'Export the whole project as a .mnteadlgproj archive (mountea.archive.v3). Returns a download link valid for 24 hours, or base64 when inline=true and the file is under 1 MB.', + { projectId: PROJECT, inline: bool('Return base64 instead of a link (small files only)') }, + ['projectId'], + async ({ projectId, inline }) => { + const snapshot = await loadSnapshot(projectId); + const zip = await buildProjectExport(snapshot); + const baseName = snapshot.project.name || 'Project'; + if (inline) { + if (zip.length > INLINE_LIMIT) throw new ToolError(`Export is ${zip.length} bytes; use a download link instead (inline=false)`); + return { fileName: `${baseName}.mnteadlgproj`, sizeBytes: zip.length, base64: zip.toString('base64') }; + } + await fs.mkdir(EXPORT_DIR, { recursive: true }); + await pruneOldExports(); + const fileName = `${baseName}-${new Date().toISOString().replace(/[:.]/g, '-')}.mnteadlgproj`; + await fs.writeFile(path.join(EXPORT_DIR, fileName), zip); + return { fileName, sizeBytes: zip.length, downloadPath: `/export/${fileName}` }; + } + ), +]; diff --git a/mcp/tools/graph.mjs b/mcp/tools/graph.mjs new file mode 100644 index 0000000..06a805d --- /dev/null +++ b/mcp/tools/graph.mjs @@ -0,0 +1,414 @@ +import { mutateProject, ToolError, uuid } from '../lib/store.mjs'; +import { + CREATABLE_TYPES, + NODE_DEFS, + START_NODE_ID, + assertEdgeAllowed, + autoLayout, + buildDecoratorInstances, + defaultNode, + getDialogue, + loadGraph, + makeEdge, + projectLocales, + saveGraph, + validateGraph, +} from '../lib/graph.mjs'; +import { filterLocalizedEntriesByDialogue, normalizeLocaleTag } from '../../src/lib/localization/stringTable.js'; +import { + DEFAULT_ROW_DURATION_MODE, + DEFAULT_ROW_EXECUTION_BEHAVIOUR, + ROW_DURATION_MODES, + ROW_EXECUTION_BEHAVIOURS, + isValidRowTiming, +} from '../../src/lib/rowTiming.js'; +import { defineTool, str, bool, num, arr, obj, omit, PROJECT, DIALOGUE } from './util.mjs'; + +const ROW = { + oneOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + id: { type: 'string' }, + text: { type: 'string' }, + duration: { type: 'number', description: 'Seconds shown (default 3)' }, + executionBehaviour: { type: 'string', enum: ROW_EXECUTION_BEHAVIOURS, description: 'automatic (default) | awaitInput (click to continue) | stopping' }, + durationMode: { type: 'string', enum: ROW_DURATION_MODES, description: 'duration (default) | override | add | calculate' }, + durationOverride: { type: 'number', description: 'Seconds, used by durationMode override/add' }, + }, + required: ['text'], + }, + ], +}; +const ROWS = { type: 'array', description: 'Dialogue rows (spoken lines): strings or {text, duration}. Replaces existing rows.', items: ROW }; +const DECORATORS = arr('Decorator instances: names of defined decorators or {name, values}. Replaces existing.', { oneOf: [{ type: 'string' }, { type: 'object', properties: { name: { type: 'string' }, values: { type: 'object' } }, required: ['name'] }] }); +const CONDITIONS = obj('Edge conditions: {mode:"all"|"any", rules:[names of defined conditions or {name, values, negate}]}'); +const POSITION = obj('Canvas position {x, y}', { x: { type: 'number' }, y: { type: 'number' } }); + +const NODE_FIELDS = { + displayName: str('Internal node name (max 64 chars). Exported as the display-name string; for player nodes the exporter swaps in selectionTitle.'), + selectionTitle: str('Player nodes: the clickable option label shown to the player'), + participant: str('Speaker: name of an existing participant (required for npc, player, complete)'), + rows: ROWS, + decorators: DECORATORS, + targetNode: str('Return node: id of the node to return to'), + targetDialogue: str('Open-child-graph node: dialogue id or name to open'), + duration: num('Delay node: seconds (0.1-30)'), + position: POSITION, +}; + +function normalizeRows(rows) { + return rows.map((row) => { + const r = typeof row === 'string' ? { text: row } : row; + if (typeof r?.text !== 'string') throw new ToolError('Each row needs a "text" string'); + const out = { id: r.id || uuid(), text: r.text, duration: typeof r.duration === 'number' ? r.duration : 3.0, audioFile: null }; + // Optional timing (same fields and defaults as the editor); defaults are not stored. + for (const [key, defaultValue] of [['executionBehaviour', DEFAULT_ROW_EXECUTION_BEHAVIOUR], ['durationMode', DEFAULT_ROW_DURATION_MODE], ['durationOverride', undefined]]) { + if (r[key] !== undefined && r[key] !== defaultValue) out[key] = r[key]; + } + if (!isValidRowTiming(out)) { + throw new ToolError(`Invalid row timing. executionBehaviour: ${ROW_EXECUTION_BEHAVIOURS.join(' | ')}; durationMode: ${ROW_DURATION_MODES.join(' | ')}; durationOverride: seconds >= 0`); + } + return out; + }); +} + +function resolveParticipant(s, name) { + const hit = s.participants.find((p) => p.name.toLowerCase() === String(name).toLowerCase()); + if (!hit) throw new ToolError(`Participant "${name}" does not exist. Existing: ${s.participants.map((p) => p.name).join(', ') || '(none)'}. Use create_participant first.`); + return hit; +} + +function resolveDialogueRef(s, ref) { + const hit = s.dialogues.find((d) => d.id === ref || d.name.toLowerCase() === String(ref).toLowerCase()); + if (!hit) throw new ToolError(`Dialogue "${ref}" not found. Existing: ${s.dialogues.map((d) => d.name).join(', ')}`); + return hit.id; +} + +/** Applies tool arguments onto a node (editor shape). `resolveNode` maps target references (aliases) to node ids. */ +function applyFields(s, node, a, resolveNode = (x) => x) { + const d = node.data; + if (a.displayName !== undefined) { + if (a.displayName.length > 64) throw new ToolError('displayName must be 64 characters or fewer'); + d.displayName = a.displayName; + } + if (a.selectionTitle !== undefined) d.selectionTitle = a.selectionTitle; + if (a.participant !== undefined) { + if (a.participant === '') { + d.participant = ''; + delete d.participantId; + } else { + const speaker = resolveParticipant(s, a.participant); + d.participant = speaker.name; + d.participantId = speaker.id; + } + } + if (a.rows !== undefined) d.dialogueRows = normalizeRows(a.rows); + if (a.decorators !== undefined) d.decorators = buildDecoratorInstances(s, a.decorators); + if (a.targetNode !== undefined) { + if (node.type !== 'returnNode') throw new ToolError('targetNode only applies to return nodes'); + d.targetNode = a.targetNode ? resolveNode(a.targetNode) : ''; + } + if (a.targetDialogue !== undefined) { + if (node.type !== 'openChildGraphNode') throw new ToolError('targetDialogue only applies to openChildGraph nodes'); + d.targetDialogue = a.targetDialogue ? resolveDialogueRef(s, a.targetDialogue) : ''; + } + if (a.duration !== undefined) { + if (node.type !== 'delayNode') throw new ToolError('duration only applies to delay nodes'); + if (a.duration < 0.1 || a.duration > 30) throw new ToolError('duration must be between 0.1 and 30 seconds'); + d.duration = a.duration; + } + if (a.position) node.position = { x: Number(a.position.x) || 0, y: Number(a.position.y) || 0 }; + if (d.label !== undefined && a.displayName !== undefined && node.type !== 'startNode') d.label = a.displayName; +} + +function makeNode(s, type, a, resolveNode) { + if (!CREATABLE_TYPES.includes(type)) throw new ToolError(`Cannot create node type "${type}". Types: ${CREATABLE_TYPES.join(', ')}`); + const node = { id: uuid(), type, position: { x: 0, y: 0 }, data: defaultNode(type) }; + applyFields(s, node, a, resolveNode); + return node; +} + +function nextPosition(nodes, edges, parentId) { + const parent = nodes.find((n) => n.id === parentId); + if (parent) { + const siblings = edges.filter((e) => e.source === parentId).length; + return { x: parent.position.x + siblings * 320, y: parent.position.y + 180 }; + } + const maxY = Math.max(0, ...nodes.map((n) => n.position?.y ?? 0)); + return { x: 0, y: maxY + 180 }; +} + +function requiredWarnings(node) { + const out = []; + for (const section of NODE_DEFS[node.type]?.sections || []) { + for (const f of section.fields || []) { + if (f.required && !String(node.data[f.id] ?? '').trim()) out.push(`required field "${f.id}" is empty`); + } + } + if (node.type === 'answerNode' && !String(node.data.selectionTitle || '').trim()) out.push('player node without selectionTitle (the option label)'); + return out; +} + +const argsOf = (args) => omit(args, ['projectId', 'dialogueId']); + +export const graphTools = [ + defineTool( + 'add_node', + `Add a node to a dialogue. Types: ${CREATABLE_TYPES.join(', ')} (leadNode = NPC line, answerNode = player option, returnNode = jump back, openChildGraphNode, completeNode = end, delayNode). Optionally connect it from an existing node.`, + { projectId: PROJECT, dialogueId: DIALOGUE, type: str('Node type', { enum: CREATABLE_TYPES }), connectFrom: str('Node id (or "start") to connect this node from'), conditions: CONDITIONS, ...NODE_FIELDS }, + ['projectId', 'dialogueId', 'type'], + (args) => + mutateProject(args.projectId, (s) => { + const { nodes, edges } = loadGraph(s, args.dialogueId); + const node = makeNode(s, args.type, argsOf(args)); + const from = args.connectFrom === 'start' ? START_NODE_ID : args.connectFrom; + if (!args.position) node.position = nextPosition(nodes, edges, from); + nodes.push(node); + if (from) { + assertEdgeAllowed(nodes, edges, from, node.id); + edges.push(makeEdge(from, node.id, s, args.conditions)); + } + saveGraph(s, args.dialogueId, nodes, edges); + return { nodeId: node.id, warnings: requiredWarnings(node) }; + }) + ), + + defineTool( + 'update_node', + 'Change a node. Only the given fields change. rows replaces all rows. Pass an empty string to clear participant/selectionTitle/targets.', + { projectId: PROJECT, dialogueId: DIALOGUE, nodeId: str('Node id'), ...NODE_FIELDS }, + ['projectId', 'dialogueId', 'nodeId'], + (args) => + mutateProject(args.projectId, (s) => { + const { nodes, edges } = loadGraph(s, args.dialogueId); + const node = nodes.find((n) => n.id === args.nodeId); + if (!node) throw new ToolError(`Node "${args.nodeId}" not found`); + const fields = omit(args, ['projectId', 'dialogueId', 'nodeId']); + if (node.type === 'startNode' && Object.keys(fields).some((k) => !['displayName', 'position'].includes(k))) { + throw new ToolError('The Start node only supports displayName and position'); + } + applyFields(s, node, fields, (id) => { + if (!nodes.some((n) => n.id === id)) throw new ToolError(`Target node "${id}" not found`); + return id; + }); + saveGraph(s, args.dialogueId, nodes, edges); + return { nodeId: node.id, warnings: requiredWarnings(node) }; + }) + ), + + defineTool( + 'delete_node', + 'Delete a node and its connections. cascade=true also deletes everything reachable below it. The Start node cannot be deleted.', + { projectId: PROJECT, dialogueId: DIALOGUE, nodeId: str('Node id'), cascade: bool('Also delete all descendants') }, + ['projectId', 'dialogueId', 'nodeId'], + (args) => + mutateProject(args.projectId, (s) => { + if (args.nodeId === START_NODE_ID) throw new ToolError('The Start node cannot be deleted'); + const g = loadGraph(s, args.dialogueId); + if (!g.nodes.some((n) => n.id === args.nodeId)) throw new ToolError(`Node "${args.nodeId}" not found`); + const doomed = new Set([args.nodeId]); + if (args.cascade) { + const queue = [args.nodeId]; + while (queue.length) { + const id = queue.shift(); + for (const e of g.edges.filter((x) => x.source === id)) { + if (e.target !== START_NODE_ID && !doomed.has(e.target)) { + doomed.add(e.target); + queue.push(e.target); + } + } + } + } + const nodes = g.nodes.filter((n) => !doomed.has(n.id)); + for (const n of nodes) if (n.type === 'returnNode' && doomed.has(n.data.targetNode)) n.data.targetNode = ''; + const edges = g.edges.filter((e) => !doomed.has(e.source) && !doomed.has(e.target)); + saveGraph(s, args.dialogueId, nodes, edges); + return { deleted: [...doomed] }; + }) + ), + + defineTool( + 'connect_nodes', + 'Connect two nodes. The Start node has exactly one output; return/openChildGraph/complete nodes have none.', + { projectId: PROJECT, dialogueId: DIALOGUE, source: str('Source node id (or "start")'), target: str('Target node id'), conditions: CONDITIONS }, + ['projectId', 'dialogueId', 'source', 'target'], + (args) => + mutateProject(args.projectId, (s) => { + const { nodes, edges } = loadGraph(s, args.dialogueId); + const source = args.source === 'start' ? START_NODE_ID : args.source; + assertEdgeAllowed(nodes, edges, source, args.target); + edges.push(makeEdge(source, args.target, s, args.conditions)); + saveGraph(s, args.dialogueId, nodes, edges); + return { connected: `${source} -> ${args.target}` }; + }) + ), + + defineTool( + 'update_edge', + 'Replace the conditions of a connection (pass empty rules to clear).', + { projectId: PROJECT, dialogueId: DIALOGUE, source: str('Source node id (or "start")'), target: str('Target node id'), conditions: CONDITIONS }, + ['projectId', 'dialogueId', 'source', 'target', 'conditions'], + (args) => + mutateProject(args.projectId, (s) => { + const { nodes, edges } = loadGraph(s, args.dialogueId); + const source = args.source === 'start' ? START_NODE_ID : args.source; + const edge = edges.find((e) => e.source === source && e.target === args.target); + if (!edge) throw new ToolError('No such connection'); + edge.data = makeEdge(source, args.target, s, args.conditions).data; + saveGraph(s, args.dialogueId, nodes, edges); + return { updated: `${source} -> ${args.target}`, rules: edge.data.conditions.rules.length }; + }) + ), + + defineTool( + 'delete_edge', + 'Remove a connection between two nodes.', + { projectId: PROJECT, dialogueId: DIALOGUE, source: str('Source node id (or "start")'), target: str('Target node id') }, + ['projectId', 'dialogueId', 'source', 'target'], + (args) => + mutateProject(args.projectId, (s) => { + const { nodes, edges } = loadGraph(s, args.dialogueId); + const source = args.source === 'start' ? START_NODE_ID : args.source; + const next = edges.filter((e) => !(e.source === source && e.target === args.target)); + if (next.length === edges.length) throw new ToolError('No such connection'); + saveGraph(s, args.dialogueId, nodes, next); + return { removed: `${source} -> ${args.target}` }; + }) + ), + + defineTool( + 'set_graph', + 'Build many nodes and connections in one call. Give each node a short "ref" alias and use refs (or "start", or existing node ids) in edges and targetNode. mode "replace" first removes every node except Start; "merge" adds to the existing graph. Returns the ref -> id map. Nodes are laid out automatically unless they have a position.', + { + projectId: PROJECT, + dialogueId: DIALOGUE, + mode: str('replace or merge (default merge)', { enum: ['replace', 'merge'] }), + nodes: arr('Nodes to create', { type: 'object', properties: { ref: { type: 'string' }, type: { type: 'string', enum: CREATABLE_TYPES }, ...NODE_FIELDS }, required: ['ref', 'type'] }), + edges: arr('Connections', { type: 'object', properties: { source: { type: 'string' }, target: { type: 'string' }, conditions: CONDITIONS }, required: ['source', 'target'] }), + layout: bool('Auto layout new nodes without a position (default true)'), + }, + ['projectId', 'dialogueId'], + (args) => + mutateProject(args.projectId, (s) => { + const g = loadGraph(s, args.dialogueId); + const replace = args.mode === 'replace'; + let nodes = replace ? g.nodes.filter((n) => n.id === START_NODE_ID) : g.nodes; + let edges = replace ? [] : g.edges; + const refs = new Map([['start', START_NODE_ID]]); + const fresh = []; + for (const spec of args.nodes || []) { + if (!spec.ref || refs.has(spec.ref)) throw new ToolError(`Duplicate or missing node ref "${spec.ref}"`); + const node = { id: uuid(), type: spec.type, position: { x: 0, y: 0 }, data: defaultNode(spec.type) }; + refs.set(spec.ref, node.id); + fresh.push({ node, spec }); + } + const resolve = (r) => { + if (refs.has(r)) return refs.get(r); + if (nodes.some((n) => n.id === r)) return r; + throw new ToolError(`Unknown node reference "${r}"`); + }; + for (const { node, spec } of fresh) { + if (!CREATABLE_TYPES.includes(spec.type)) throw new ToolError(`Cannot create node type "${spec.type}"`); + applyFields(s, node, omit(spec, ['ref', 'type']), resolve); + } + const base = nodes.length; + nodes = nodes.concat(fresh.map((f) => f.node)); + for (const e of args.edges || []) { + const source = resolve(e.source); + const target = resolve(e.target); + assertEdgeAllowed(nodes, edges, source, target); + edges = edges.concat(makeEdge(source, target, s, e.conditions)); + } + if (args.layout !== false) { + const needs = fresh.filter((f) => !f.spec.position).map((f) => f.node); + if (needs.length) { + if (replace || base <= 1) autoLayout(nodes, edges); + else { + const sub = new Set(needs.map((n) => n.id)); + const existingMaxY = Math.max(0, ...nodes.filter((n) => !sub.has(n.id)).map((n) => n.position?.y ?? 0)); + const subNodes = [{ id: START_NODE_ID, position: { x: 0, y: 0 } }, ...needs]; + autoLayout(subNodes, edges.filter((e) => sub.has(e.target) || sub.has(e.source)).map((e) => ({ ...e, source: sub.has(e.source) ? e.source : START_NODE_ID }))); + for (const n of needs) n.position = { x: n.position.x, y: n.position.y + existingMaxY }; + } + } + } + saveGraph(s, args.dialogueId, nodes, edges); + const report = validateGraph(s, args.dialogueId); + return { refs: Object.fromEntries([...refs].filter(([k]) => k !== 'start')), nodeCount: nodes.length, edgeCount: edges.length, errors: report.errors, warnings: report.warnings }; + }) + ), + + defineTool( + 'clear_dialogue', + 'Remove all content of a dialogue (every node except Start, and all connections) but keep the dialogue itself. Backups are kept server-side.', + { projectId: PROJECT, dialogueId: DIALOGUE }, + ['projectId', 'dialogueId'], + ({ projectId, dialogueId }) => + mutateProject(projectId, (s) => { + const { nodes, edges } = loadGraph(s, dialogueId); + saveGraph(s, dialogueId, nodes.filter((n) => n.id === START_NODE_ID), []); + return { removedNodes: nodes.length - 1, removedEdges: edges.length }; + }) + ), + + defineTool('auto_layout', 'Rearrange all nodes of a dialogue in a tidy layered layout.', { projectId: PROJECT, dialogueId: DIALOGUE }, ['projectId', 'dialogueId'], ({ projectId, dialogueId }) => + mutateProject(projectId, (s) => { + const { nodes, edges } = loadGraph(s, dialogueId); + autoLayout(nodes, edges); + saveGraph(s, dialogueId, nodes, edges); + return { nodeCount: nodes.length }; + }) + ), + + defineTool( + 'set_text', + 'Set translated text (a non-default locale, or any locale) for nodes. Each item: nodeId, field (displayName | selectionTitle | rowText), rowIndex (for rowText, 0-based), text. The locale must be supported by the project (add it with update_project addLocales).', + { projectId: PROJECT, dialogueId: DIALOGUE, locale: str('Locale tag, e.g. "de"'), items: arr('Texts to set', { type: 'object', properties: { nodeId: { type: 'string' }, field: { type: 'string', enum: ['displayName', 'selectionTitle', 'rowText'] }, rowIndex: { type: 'number' }, text: { type: 'string' } }, required: ['nodeId', 'field', 'text'] }) }, + ['projectId', 'dialogueId', 'locale', 'items'], + (args) => + mutateProject(args.projectId, (s) => { + getDialogue(s, args.dialogueId); + const locale = normalizeLocaleTag(args.locale, ''); + const { supportedLocales } = projectLocales(s); + if (!locale || !supportedLocales.includes(locale)) throw new ToolError(`Locale "${args.locale}" is not enabled. Enabled: ${supportedLocales.join(', ')}. Add it with update_project addLocales.`); + let count = 0; + for (const item of args.items) { + const node = s.nodes.find((n) => n.dialogueId === args.dialogueId && n.id === item.nodeId); + if (!node) throw new ToolError(`Node "${item.nodeId}" not found`); + const key = + item.field === 'displayName' + ? node.data?.displayNameKey + : item.field === 'selectionTitle' + ? node.data?.selectionTitleKey + : node.data?.dialogueRows?.[item.rowIndex ?? 0]?.textKey; + const entry = key && s.localizedStrings.find((e) => e.key === key && e.dialogueId === args.dialogueId); + if (!entry) throw new ToolError(`Node ${item.nodeId} has no ${item.field}${item.field === 'rowText' ? ` row ${item.rowIndex ?? 0}` : ''} to translate`); + entry.values = { ...(entry.values || {}), [locale]: item.text }; + entry.modifiedAt = new Date().toISOString(); + count += 1; + } + getDialogue(s, args.dialogueId).modifiedAt = new Date().toISOString(); + return { updated: count, locale }; + }) + ), + + defineTool( + 'list_missing_translations', + 'List texts that have no value in the given locale.', + { projectId: PROJECT, dialogueId: str('Limit to one dialogue (optional)'), locale: str('Locale tag') }, + ['projectId', 'locale'], + async (args) => { + const { loadSnapshot } = await import('../lib/store.mjs'); + const s = await loadSnapshot(args.projectId); + const locale = normalizeLocaleTag(args.locale, ''); + const entries = args.dialogueId ? filterLocalizedEntriesByDialogue(s.localizedStrings, args.dialogueId) : s.localizedStrings; + const base = projectLocales(s).defaultLocale; + const missing = entries + .filter((e) => (e.values?.[base] ?? '') !== '' && !(e.values?.[locale] ?? '')) + .map((e) => ({ dialogueId: e.dialogueId, nodeId: e.nodeId, field: e.field, rowId: e.rowId || undefined, source: e.values[base] })); + return { locale, count: missing.length, missing: missing.slice(0, 200) }; + } + ), +]; diff --git a/mcp/tools/index.mjs b/mcp/tools/index.mjs new file mode 100644 index 0000000..81c03df --- /dev/null +++ b/mcp/tools/index.mjs @@ -0,0 +1,14 @@ +import { projectTools } from './projects.mjs'; +import { graphTools } from './graph.mjs'; +import { peopleTools } from './people.mjs'; +import { defTools } from './defs.mjs'; +import { exportTools } from './export.mjs'; + +export const TOOLS = [...projectTools, ...graphTools, ...peopleTools, ...defTools, ...exportTools]; + +// Deleting whole projects or dialogues is deliberately not available through MCP. +const FORBIDDEN = ['delete_project', 'delete_dialogue', 'remove_project', 'remove_dialogue']; +for (const tool of TOOLS) { + if (FORBIDDEN.includes(tool.name)) throw new Error(`Tool ${tool.name} must not exist`); +} +export const TOOL_MAP = new Map(TOOLS.map((tool) => [tool.name, tool])); diff --git a/mcp/tools/people.mjs b/mcp/tools/people.mjs new file mode 100644 index 0000000..b9a6dab --- /dev/null +++ b/mcp/tools/people.mjs @@ -0,0 +1,197 @@ +import { loadSnapshot, mutateProject, nowIso, ToolError, uuid } from '../lib/store.mjs'; +import { + MAX_NAME_LENGTH, + assertCategoryPlacement, + assertUniqueCategoryName, + assertUniqueParticipant, + cleanName, +} from '../lib/rules.mjs'; +import { defineTool, str, bool, PROJECT } from './util.mjs'; + +const MAX_THUMBNAIL_BYTES = 1024 * 1024; + +const findCategory = (categories, ref, label = 'Category') => { + const hit = categories.find((c) => c.id === ref || c.name.toLowerCase() === String(ref).toLowerCase()); + if (!hit) throw new ToolError(`${label} "${ref}" not found. Existing: ${categories.map((c) => c.name).join(', ') || '(none)'}`); + return hit; +}; +const findParticipant = (participants, ref) => { + const hit = participants.find((p) => p.id === ref || p.name.toLowerCase() === String(ref).toLowerCase()); + if (!hit) throw new ToolError(`Participant "${ref}" not found. Existing: ${participants.map((p) => p.name).join(', ') || '(none)'}`); + return hit; +}; +const participantUsage = (s, participant) => s.nodes.filter((n) => n.data?.participantId === participant.id || (!n.data?.participantId && n.data?.participant === participant.name)).length; + +function pngThumbnail(base64) { + const raw = String(base64).replace(/^data:image\/png;base64,/, ''); + const bytes = Buffer.from(raw, 'base64'); + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + if (bytes.length < 24 || !bytes.subarray(0, 8).equals(signature)) throw new ToolError('Thumbnail must be a PNG image (base64)'); + if (bytes.length > MAX_THUMBNAIL_BYTES) throw new ToolError('Thumbnail must be 1 MiB or smaller'); + return { + base64: `data:image/png;base64,${bytes.toString('base64')}`, + name: 'thumbnail.png', + mimeType: 'image/png', + size: bytes.length, + sizeBytes: bytes.length, + width: bytes.readUInt32BE(16), + height: bytes.readUInt32BE(20), + updatedAt: nowIso(), + }; +} + +export const peopleTools = [ + defineTool('list_participants', 'List participants (speakers) of a project.', { projectId: PROJECT }, ['projectId'], async ({ projectId }) => { + const s = await loadSnapshot(projectId); + return { participants: s.participants.map((p) => ({ id: p.id, name: p.name, category: p.category, hasThumbnail: Boolean(p.thumbnail), usedByNodes: participantUsage(s, p) })) }; + }), + + defineTool( + 'create_participant', + `Create a participant. Name: PascalCase, letters/numbers/_/-, max ${MAX_NAME_LENGTH} chars, unique within its category tree. The category must exist (create_category).`, + { projectId: PROJECT, name: str('Participant name'), category: str('Category name or id') }, + ['projectId', 'name', 'category'], + (args) => + mutateProject(args.projectId, (s) => { + const name = cleanName(args.name, 'Participant', { maxLength: MAX_NAME_LENGTH }); + const category = findCategory(s.categories, args.category); + assertUniqueParticipant(name, category.name, s.participants, s.categories); + const now = nowIso(); + const participant = { id: uuid(), projectId: args.projectId, name, category: category.name, categoryId: category.id, thumbnail: null, createdAt: now, modifiedAt: now }; + s.participants.push(participant); + return { participantId: participant.id, name, category: category.name }; + }) + ), + + defineTool( + 'update_participant', + 'Rename a participant and/or move it to another category. A rename also updates every node that uses it.', + { projectId: PROJECT, participant: str('Participant name or id'), name: str('New name'), category: str('New category name or id') }, + ['projectId', 'participant'], + (args) => + mutateProject(args.projectId, (s) => { + const p = findParticipant(s.participants, args.participant); + const oldName = p.name; + const name = args.name !== undefined ? cleanName(args.name, 'Participant', { maxLength: MAX_NAME_LENGTH }) : p.name; + const category = args.category !== undefined ? findCategory(s.categories, args.category) : s.categories.find((c) => c.id === p.categoryId) || findCategory(s.categories, p.category); + const categoryName = category.name; + assertUniqueParticipant(name, categoryName, s.participants, s.categories, p.id); + p.name = name; + p.category = categoryName; + p.categoryId = category.id; + p.modifiedAt = nowIso(); + let renamedIn = 0; + if (name !== oldName) { + for (const n of s.nodes) { + if (n.data?.participantId === p.id || (!n.data?.participantId && n.data?.participant === oldName)) { + n.data.participant = name; + renamedIn += 1; + } + } + } + return { name, category: categoryName, nodesUpdated: renamedIn }; + }) + ), + + defineTool( + 'delete_participant', + 'Delete a participant. Refused while nodes still use it, unless force=true (those nodes then keep a dangling name).', + { projectId: PROJECT, participant: str('Participant name or id'), force: bool('Delete even if nodes use it') }, + ['projectId', 'participant'], + (args) => + mutateProject(args.projectId, (s) => { + const p = findParticipant(s.participants, args.participant); + const used = participantUsage(s, p); + if (used && !args.force) throw new ToolError(`Participant "${p.name}" is used by ${used} node(s). Reassign them or pass force=true.`); + s.participants = s.participants.filter((x) => x.id !== p.id); + return { deleted: p.name, nodesStillReferencing: used }; + }) + ), + + defineTool( + 'set_participant_thumbnail', + 'Set (or clear) a participant portrait. PNG, base64 (with or without data: prefix), max 1 MiB. Omit pngBase64 to clear.', + { projectId: PROJECT, participant: str('Participant name or id'), pngBase64: str('PNG image, base64') }, + ['projectId', 'participant'], + (args) => + mutateProject(args.projectId, (s) => { + const p = findParticipant(s.participants, args.participant); + p.thumbnail = args.pngBase64 ? pngThumbnail(args.pngBase64) : null; + p.modifiedAt = nowIso(); + return { participant: p.name, thumbnail: p.thumbnail ? { width: p.thumbnail.width, height: p.thumbnail.height, sizeBytes: p.thumbnail.sizeBytes } : null }; + }) + ), + + defineTool('list_categories', 'List participant categories as a tree.', { projectId: PROJECT }, ['projectId'], async ({ projectId }) => { + const s = await loadSnapshot(projectId); + const build = (parent) => + s.categories + .filter((c) => (c.parentCategoryId || null) === parent) + .map((c) => ({ id: c.id, name: c.name, participants: s.participants.filter((p) => p.category === c.name).map((p) => p.name), children: build(c.id) })); + return { categories: build(null) }; + }), + + defineTool( + 'create_category', + `Create a participant category (max depth 5, name max ${MAX_NAME_LENGTH} chars, unique within its tree). Omit parent for a top-level category.`, + { projectId: PROJECT, name: str('Category name'), parent: str('Parent category name or id') }, + ['projectId', 'name'], + (args) => + mutateProject(args.projectId, (s) => { + const name = cleanName(args.name, 'Category', { maxLength: MAX_NAME_LENGTH }); + const parent = args.parent ? findCategory(s.categories, args.parent, 'Parent category') : null; + assertCategoryPlacement(null, parent?.id, s.categories); + assertUniqueCategoryName(name, parent?.id || null, s.categories); + const now = nowIso(); + const category = { id: uuid(), projectId: args.projectId, name, parentCategoryId: parent?.id || null, createdAt: now, modifiedAt: now }; + s.categories.push(category); + return { categoryId: category.id, name, parent: parent?.name || null }; + }) + ), + + defineTool( + 'update_category', + 'Rename a category and/or move it (parent "" = move to top level). A rename also updates the participants that use it.', + { projectId: PROJECT, category: str('Category name or id'), name: str('New name'), parent: str('New parent name or id; empty string for top level') }, + ['projectId', 'category'], + (args) => + mutateProject(args.projectId, (s) => { + const c = findCategory(s.categories, args.category); + const oldName = c.name; + const name = args.name !== undefined ? cleanName(args.name, 'Category', { maxLength: MAX_NAME_LENGTH }) : c.name; + let parentId = c.parentCategoryId || null; + if (args.parent !== undefined) parentId = args.parent === '' ? null : findCategory(s.categories, args.parent, 'Parent category').id; + assertCategoryPlacement(c.id, parentId, s.categories); + assertUniqueCategoryName(name, parentId, s.categories, c.id); + c.name = name; + c.parentCategoryId = parentId; + c.modifiedAt = nowIso(); + let moved = 0; + if (name !== oldName) { + for (const p of s.participants) { + if (p.categoryId === c.id || (!p.categoryId && p.category === oldName)) { + p.category = name; + moved += 1; + } + } + } + return { name, parentCategoryId: parentId, participantsUpdated: moved }; + }) + ), + + defineTool( + 'delete_category', + 'Delete a category. Refused while it has child categories or participants; move or delete those first.', + { projectId: PROJECT, category: str('Category name or id') }, + ['projectId', 'category'], + (args) => + mutateProject(args.projectId, (s) => { + const c = findCategory(s.categories, args.category); + if (s.categories.some((x) => x.parentCategoryId === c.id)) throw new ToolError(`Category "${c.name}" has child categories`); + const users = s.participants.filter((p) => p.categoryId === c.id || (!p.categoryId && p.category === c.name)); + if (users.length) throw new ToolError(`Category "${c.name}" is used by participants: ${users.map((p) => p.name).join(', ')}`); + s.categories = s.categories.filter((x) => x.id !== c.id); + return { deleted: c.name }; + }) + ), +]; diff --git a/mcp/tools/projects.mjs b/mcp/tools/projects.mjs new file mode 100644 index 0000000..ef0b7e7 --- /dev/null +++ b/mcp/tools/projects.mjs @@ -0,0 +1,178 @@ +import { createProjectFile, listAllProjects, loadSnapshot, mutateProject, nowIso, ToolError, uuid } from '../lib/store.mjs'; +import { cleanName } from '../lib/rules.mjs'; +import { projectLocales, saveGraph, startNode, getDialogue, loadGraph, validateGraph, START_NODE_ID } from '../lib/graph.mjs'; +import { allocateDialogueLocalizationSlug, isValidLocaleTag, normalizeLocaleTag, normalizeProjectLocalizationConfig } from '../../src/lib/localization/stringTable.js'; +import { defineTool, str, arr, PROJECT, DIALOGUE } from './util.mjs'; + +const summarizeNode = (node) => { + const d = node.data || {}; + const out = { id: node.id, type: node.type, position: node.position && { x: Math.round(node.position.x), y: Math.round(node.position.y) } }; + if (d.displayName !== undefined) out.displayName = d.displayName; + if (d.selectionTitle) out.selectionTitle = d.selectionTitle; + if (d.participant) out.participant = d.participant; + if (Array.isArray(d.dialogueRows) && d.dialogueRows.length) { + out.rows = d.dialogueRows.map((r) => ({ id: r.id, text: r.text, duration: r.duration, hasAudio: Boolean(r.audioFile) })); + } + if (d.decorators?.length) out.decorators = d.decorators.map((x) => ({ name: x.name, values: x.values })); + if (d.targetNode) out.targetNode = d.targetNode; + if (d.targetDialogue) out.targetDialogue = d.targetDialogue; + if (d.duration !== undefined && node.type === 'delayNode') out.duration = d.duration; + return out; +}; +export const summarizeEdge = (edge) => ({ + source: edge.source, + target: edge.target, + conditions: (edge.data?.conditions?.rules || []).length + ? { mode: edge.data.conditions.mode, rules: edge.data.conditions.rules.map((r) => ({ name: r.name, values: r.values, negate: r.negate })) } + : undefined, +}); + +function categoryTree(categories) { + const build = (parentId) => + categories + .filter((c) => (c.parentCategoryId || null) === parentId) + .map((c) => ({ name: c.name, id: c.id, children: build(c.id) })); + return build(null); +} + +export const projectTools = [ + defineTool('list_projects', 'List all projects (id, name, last modified).', {}, [], async () => ({ + projects: await listAllProjects(), + })), + + defineTool('get_project', 'Overview of one project: settings, locales, dialogues, participants, category tree, decorator and condition definitions.', { projectId: PROJECT }, ['projectId'], async ({ projectId }) => { + const s = await loadSnapshot(projectId); + const locales = projectLocales(s); + return { + project: { id: s.project.id, name: s.project.name, description: s.project.description, version: s.project.version, modifiedAt: s.project.modifiedAt, ...locales }, + dialogues: s.dialogues.map((d) => ({ id: d.id, name: d.name, nodeCount: s.nodes.filter((n) => n.dialogueId === d.id).length })), + participants: s.participants.map((p) => ({ id: p.id, name: p.name, category: p.category, hasThumbnail: Boolean(p.thumbnail) })), + categories: categoryTree(s.categories), + decorators: s.decorators.map((d) => ({ id: d.id, name: d.name, properties: d.properties })), + conditions: s.conditions.map((c) => ({ id: c.id, name: c.name, properties: c.properties })), + }; + }), + + defineTool( + 'create_project', + 'Create a project. The name is converted to PascalCase (spaces removed; letters and numbers only).', + { name: str('Project name'), description: str('Description'), defaultLocale: str('Default locale tag, e.g. "en" (default "en")'), supportedLocales: arr('Locale tags, must include the default', { type: 'string' }) }, + ['name'], + async ({ name, description, defaultLocale, supportedLocales }) => { + const now = nowIso(); + const localization = normalizeProjectLocalizationConfig({ defaultLocale: defaultLocale || 'en', supportedLocales: supportedLocales || [defaultLocale || 'en'] }); + if (defaultLocale && !isValidLocaleTag(defaultLocale)) throw new ToolError(`Invalid locale tag "${defaultLocale}"`); + const project = { + id: uuid(), + name: cleanName(name, 'Project'), + description: description || '', + version: '1.0.0', + localization, + isExample: false, + createdAt: now, + modifiedAt: now, + }; + await createProjectFile({ version: 2, project, dialogues: [], participants: [], categories: [], decorators: [], conditions: [], localizedStrings: [], nodes: [], edges: [] }); + return { projectId: project.id, name: project.name, ...localization }; + } + ), + + defineTool( + 'update_project', + 'Change project name, description, version or locales. Adding locales is safe. Changing the default locale copies the old default text into the new default where it is missing. Removing a locale deletes its translations.', + { projectId: PROJECT, name: str('New name'), description: str('Description'), version: str('Version string'), defaultLocale: str('New default locale (must be supported)'), addLocales: arr('Locale tags to add', { type: 'string' }), removeLocales: arr('Locale tags to remove (not the default)', { type: 'string' }) }, + ['projectId'], + (args) => + mutateProject(args.projectId, (s) => { + const p = s.project; + if (args.name !== undefined) p.name = cleanName(args.name, 'Project'); + if (args.description !== undefined) p.description = args.description; + if (args.version !== undefined) p.version = args.version; + const current = projectLocales(s); + let supported = [...current.supportedLocales]; + let def = current.defaultLocale; + for (const tag of args.addLocales || []) { + if (!isValidLocaleTag(tag)) throw new ToolError(`Invalid locale tag "${tag}"`); + const n = normalizeLocaleTag(tag, ''); + if (!supported.includes(n)) supported.push(n); + } + if (args.defaultLocale) { + if (!isValidLocaleTag(args.defaultLocale)) throw new ToolError(`Invalid locale tag "${args.defaultLocale}"`); + def = normalizeLocaleTag(args.defaultLocale, def); + if (!supported.includes(def)) supported.push(def); + } + for (const tag of args.removeLocales || []) { + const n = normalizeLocaleTag(tag, ''); + if (n === def) throw new ToolError('Cannot remove the default locale'); + supported = supported.filter((l) => l !== n); + for (const entry of s.localizedStrings) if (entry.values) delete entry.values[n]; + } + if (def !== current.defaultLocale) { + for (const entry of s.localizedStrings) { + entry.values = entry.values || {}; + if (entry.values[def] === undefined || entry.values[def] === '') entry.values[def] = entry.values[current.defaultLocale] ?? ''; + } + } + p.localization = normalizeProjectLocalizationConfig({ defaultLocale: def, supportedLocales: supported }); + return { name: p.name, ...projectLocales(s) }; + }) + ), + + defineTool('list_dialogues', 'List the dialogues of a project.', { projectId: PROJECT }, ['projectId'], async ({ projectId }) => { + const s = await loadSnapshot(projectId); + return { + dialogues: s.dialogues.map((d) => ({ id: d.id, name: d.name, description: d.description, nodeCount: s.nodes.filter((n) => n.dialogueId === d.id).length, modifiedAt: d.modifiedAt })), + }; + }), + + defineTool( + 'create_dialogue', + 'Create a dialogue (with its Start node). Name becomes PascalCase and must be unique in the project.', + { projectId: PROJECT, name: str('Dialogue name'), description: str('Description') }, + ['projectId', 'name'], + ({ projectId, name, description }) => + mutateProject(projectId, (s) => { + const clean = cleanName(name, 'Dialogue'); + if (s.dialogues.some((d) => d.name.toLowerCase() === clean.toLowerCase())) throw new ToolError(`A dialogue named "${clean}" already exists`); + const now = nowIso(); + const dialogue = { id: uuid(), projectId, name: clean, description: description || '', localizationVersion: 2, createdAt: now, modifiedAt: now, viewport: { x: 540, y: 253, zoom: 1 } }; + dialogue.localizationSlug = allocateDialogueLocalizationSlug(dialogue, s.dialogues); + s.dialogues.push(dialogue); + saveGraph(s, dialogue.id, [startNode()], []); + return { dialogueId: dialogue.id, name: clean, startNodeId: START_NODE_ID }; + }) + ), + + defineTool( + 'update_dialogue', + 'Rename a dialogue or change its description. Localization keys stay stable on rename.', + { projectId: PROJECT, dialogueId: DIALOGUE, name: str('New name'), description: str('Description') }, + ['projectId', 'dialogueId'], + ({ projectId, dialogueId, name, description }) => + mutateProject(projectId, (s) => { + const d = getDialogue(s, dialogueId); + if (name !== undefined) { + const clean = cleanName(name, 'Dialogue'); + if (s.dialogues.some((x) => x.id !== d.id && x.name.toLowerCase() === clean.toLowerCase())) throw new ToolError(`A dialogue named "${clean}" already exists`); + d.name = clean; + } + if (description !== undefined) d.description = description; + d.modifiedAt = nowIso(); + return { name: d.name, description: d.description }; + }) + ), + + defineTool( + 'get_dialogue', + 'Read a dialogue graph: nodes (text for the given locale) and edges. Use node ids from here in other tools.', + { projectId: PROJECT, dialogueId: DIALOGUE, locale: str('Locale tag; default is the project default (falls back to it for untranslated text)') }, + ['projectId', 'dialogueId'], + async ({ projectId, dialogueId, locale }) => { + const s = await loadSnapshot(projectId); + const g = loadGraph(s, dialogueId, locale); + return { dialogue: { id: g.dialogue.id, name: g.dialogue.name, description: g.dialogue.description }, locale: locale || projectLocales(s).defaultLocale, nodes: g.nodes.map(summarizeNode), edges: g.edges.map(summarizeEdge) }; + } + ), + + defineTool('validate_dialogue', 'Check a dialogue: required fields, missing participants, bad targets, unreachable nodes, option labels. Does not change anything.', { projectId: PROJECT, dialogueId: DIALOGUE }, ['projectId', 'dialogueId'], async ({ projectId, dialogueId }) => validateGraph(await loadSnapshot(projectId), dialogueId)), +]; diff --git a/mcp/tools/util.mjs b/mcp/tools/util.mjs new file mode 100644 index 0000000..0d17055 --- /dev/null +++ b/mcp/tools/util.mjs @@ -0,0 +1,53 @@ +import { ToolError } from '../lib/store.mjs'; + +/** Tool definition helper: schema is a JSON Schema "properties" map; `required` lists mandatory keys. */ +export function defineTool(name, description, properties, required, handler) { + return { + name, + description, + inputSchema: { type: 'object', properties, required, additionalProperties: false }, + handler, + }; +} + +export const str = (description, extra = {}) => ({ type: 'string', description, ...extra }); +export const bool = (description) => ({ type: 'boolean', description }); +export const num = (description) => ({ type: 'number', description }); +export const arr = (description, items = {}) => ({ type: 'array', description, items }); +export const obj = (description, properties = {}) => ({ type: 'object', description, properties }); + +export const PROJECT = str('Project id (see list_projects)'); +export const DIALOGUE = str('Dialogue id (see list_dialogues)'); + +/** Minimal JSON Schema check: required keys, unknown keys, primitive types, enums. */ +export function validateArgs(tool, args) { + const schema = tool.inputSchema; + const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {}; + for (const key of schema.required || []) { + if (input[key] === undefined || input[key] === null || input[key] === '') throw new ToolError(`Missing required argument "${key}"`); + } + for (const [key, value] of Object.entries(input)) { + const prop = schema.properties[key]; + if (!prop) throw new ToolError(`Unknown argument "${key}". Allowed: ${Object.keys(schema.properties).join(', ')}`); + if (value === null || value === undefined) continue; + const type = Array.isArray(value) ? 'array' : typeof value; + const expected = prop.type; + if (expected && expected !== type && !(expected === 'number' && type === 'number')) { + throw new ToolError(`Argument "${key}" must be ${expected}, got ${type}`); + } + if (prop.enum && !prop.enum.includes(value)) throw new ToolError(`Argument "${key}" must be one of: ${prop.enum.join(', ')}`); + } + return input; +} + +export function pick(object, keys) { + const out = {}; + for (const key of keys) if (object[key] !== undefined) out[key] = object[key]; + return out; +} + +export function omit(object, keys) { + const out = { ...object }; + for (const key of keys) delete out[key]; + return out; +} diff --git a/package-lock.json b/package-lock.json index b43fb5c..d40cf5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "vite": "^6.0.5", + "vitest": "^5.0.3", "wait-on": "^9.0.1" } }, @@ -126,7 +127,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -676,6 +676,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1384,9 +1385,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1529,7 +1530,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1551,7 +1551,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1567,7 +1566,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", @@ -1618,7 +1616,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", @@ -3422,7 +3419,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.157.18.tgz", "integrity": "sha512-qs//HcVhEZ0K2/Sqejol0vOWaFIh4EoYTQQix9FhHOyWvdUpGoTJS0+g/qxEnZZm7r9QNOrnyrYZ5CDAqnII6g==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", @@ -3494,7 +3490,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.157.18.tgz", "integrity": "sha512-jGkyA3EEE01Sf6d4goi//poxQNb/Odc/GzpjZSW2zwG+wcXm9hEzcI6vU2IxhAU0dvvwQyQgtU1HXTcXQ/Xg4A==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", @@ -3753,6 +3748,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -3812,6 +3818,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3873,7 +3886,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3885,7 +3897,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3928,6 +3939,44 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", @@ -4014,7 +4063,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4582,6 +4630,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -4886,7 +4944,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", @@ -5103,6 +5160,16 @@ "url": "https://www.paypal.me/kirilvatev" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5376,7 +5443,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -5428,8 +5496,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3-color": { "version": "3.1.0", @@ -5488,7 +5555,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5743,8 +5809,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.3.0.tgz", "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/dexie-react-hooks": { "version": "1.1.7", @@ -5811,7 +5876,6 @@ "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", @@ -6037,6 +6101,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6057,6 +6122,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6072,6 +6138,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -6082,6 +6149,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -6097,8 +6165,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-autoplay": { "version": "8.6.0", @@ -6431,7 +6498,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6662,6 +6728,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6672,6 +6748,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -7585,7 +7671,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.26.10" }, @@ -8205,7 +8290,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -8484,6 +8568,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -8646,6 +8740,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -9030,6 +9125,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9384,7 +9493,6 @@ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -9463,7 +9571,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", @@ -9662,6 +9769,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9679,6 +9787,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -9859,7 +9968,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9872,7 +9980,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10614,7 +10721,6 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.8.tgz", "integrity": "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" } @@ -10900,6 +11006,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/steamworks.js": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/steamworks.js/-/steamworks.js-0.4.0.tgz", @@ -11294,6 +11407,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -11335,6 +11449,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -11404,15 +11519,35 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11445,7 +11580,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11925,7 +12059,6 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -12503,7 +12636,109 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -12691,6 +12926,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index d46d053..0a0729e 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,11 @@ "electron:dist": "node scripts/run-electron-builder.mjs --public-release", "electron:dist:steam": "cross-env VITE_DIST_CHANNEL=steam STEAM_APP_ID=4509320 node scripts/run-electron-builder.mjs --public-release --config.extraMetadata.mounteaDistChannel=steam --config.extraMetadata.mounteaSteamAppId=4509320", "steam:upload:mac": "node scripts/steam-upload.mjs --platform macos", - "lint": "eslint src electron scripts tests *.config.js .eslintrc.cjs --ext js,jsx,cjs,mjs", + "lint": "eslint src electron scripts tests mcp server *.config.js .eslintrc.cjs --ext js,jsx,cjs,mjs", "lint:ci": "npm run lint", + "start": "node server/index.mjs", + "mcp": "node --import ./mcp/register.mjs mcp/server.mjs", + "test:mcp": "vitest run mcp", "test:e2e": "playwright test", "test:e2e:smoke": "playwright test tests/e2e/smoke.spec.js", "test:e2e:evidence": "npx playwright test tests/e2e/evidence.spec.js --reporter=list --workers=1", @@ -145,6 +148,7 @@ "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "vite": "^6.0.5", + "vitest": "^5.0.3", "wait-on": "^9.0.1" } } diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..b3e417f --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,157 @@ +// Mountea Dialoguer server: serves the built web app and is the file store for its revision sync. +// No dependencies. Auth is expected to be handled in front (e.g. Cloudflare Access). +import http from 'node:http'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + DATA_DIR, + FileExistsError, + createFile, + deleteFile, + ensureDirs, + listFiles, + readFileText, + updateFile, +} from './storage.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.PORT || 8080); +const DIST_DIR = path.resolve(process.env.DIST_DIR || path.join(__dirname, '..', 'dist')); +const MAX_BODY = Number(process.env.MAX_BODY_MB || 100) * 1024 * 1024; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.map': 'application/json', + '.wasm': 'application/wasm', + '.txt': 'text/plain; charset=utf-8', +}; + +async function readBody(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY) throw Object.assign(new Error('Payload too large'), { status: 413 }); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function send(res, status, body, headers = {}) { + const isObj = body !== null && typeof body === 'object' && !Buffer.isBuffer(body); + const payload = isObj ? JSON.stringify(body) : body; + res.writeHead(status, { + ...(isObj ? { 'Content-Type': 'application/json; charset=utf-8' } : {}), + 'Cache-Control': 'no-store', + ...headers, + }); + res.end(payload); +} + +// File store behind the web app's sync protocol (src/lib/sync/providers/storageProviders.js). +// GET /api/sync/files?prefix=&name= list files -> { files: [{ id, name }] } +// GET /api/sync/files/:name file content (text) +// POST /api/sync/files {name, content} create (409 if the file exists) +// PUT /api/sync/files/:name {content} replace +// DELETE /api/sync/files/:name +async function handleApi(req, res, url) { + const parts = url.pathname.split('/').filter(Boolean); // ['api','sync','files',':name'] + if (parts[1] === 'health') return send(res, 200, { ok: true }); + if (parts[1] !== 'sync' || parts[2] !== 'files') return send(res, 404, { error: 'Not found' }); + + const name = parts[3] ? decodeURIComponent(parts[3]) : ''; + + if (!name && req.method === 'GET') { + return send(res, 200, { files: await listFiles({ prefix: url.searchParams.get('prefix') || '', name: url.searchParams.get('name') || '' }) }); + } + + if (!name && req.method === 'POST') { + let body; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Invalid JSON' }); + } + if (typeof body?.name !== 'string' || typeof body?.content !== 'string') return send(res, 400, { error: 'name and content are required' }); + try { + return send(res, 201, await createFile(body.name, body.content)); + } catch (error) { + if (error instanceof FileExistsError) return send(res, 409, { error: error.message }); + throw error; + } + } + + if (name && req.method === 'GET') { + const text = await readFileText(name); + return text === null ? send(res, 404, { error: 'Not found' }) : send(res, 200, text, { 'Content-Type': 'application/json; charset=utf-8' }); + } + + if (name && req.method === 'PUT') { + let body; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Invalid JSON' }); + } + if (typeof body?.content !== 'string') return send(res, 400, { error: 'content is required' }); + return send(res, 200, await updateFile(name, body.content)); + } + + if (name && req.method === 'DELETE') { + await deleteFile(name); + return send(res, 200, { ok: true }); + } + + return send(res, 405, { error: 'Method not allowed' }); +} + +async function serveStatic(req, res, url) { + let rel = decodeURIComponent(url.pathname); + if (rel.endsWith('/')) rel += 'index.html'; + let file = path.normalize(path.join(DIST_DIR, rel)); + if (!file.startsWith(DIST_DIR)) return send(res, 403, 'Forbidden'); + let stat = await fs.stat(file).catch(() => null); + if (!stat || !stat.isFile()) { + // Single-page app: unknown paths get index.html. + file = path.join(DIST_DIR, 'index.html'); + stat = await fs.stat(file).catch(() => null); + if (!stat) return send(res, 404, 'Not built. Run npm run build.'); + } + const ext = path.extname(file).toLowerCase(); + const hashed = /\/assets\//.test(file); + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': hashed ? 'public, max-age=31536000, immutable' : 'no-cache', + }); + res.end(await fs.readFile(file)); +} + +await ensureDirs(); + +http + .createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url); + if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed'); + return await serveStatic(req, res, url); + } catch (error) { + console.error('[server]', error); + send(res, error.status || 500, { error: error.message || 'Server error' }); + } + }) + .listen(PORT, () => { + console.log(`Mountea Dialoguer on :${PORT} data=${DATA_DIR} dist=${DIST_DIR}`); + }); diff --git a/server/storage.mjs b/server/storage.mjs new file mode 100644 index 0000000..16b50d7 --- /dev/null +++ b/server/storage.mjs @@ -0,0 +1,93 @@ +// Shared disk storage for the app server and the MCP server. +// The web app syncs through immutable revision files (see src/lib/sync/core/revisionProtocol.js); this module is the +// "file store" behind that protocol: DATA_DIR/sync/. A file is never rewritten once created, so the +// revision history doubles as the backup history. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +export const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(here, '..', 'data')); +export const SYNC_DIR = path.join(DATA_DIR, 'sync'); +export const LEGACY_PROJECTS_DIR = path.join(DATA_DIR, 'projects'); +export const MAX_FILE_BYTES = 20 * 1024 * 1024; // same cap as MAX_SYNC_PAYLOAD_BYTES in the web app + +// Only names the protocol uses. No path separators, no hidden files. +export const isValidFileName = (name) => /^mountea-[A-Za-z0-9._-]{1,200}$/.test(name || '') && !name.includes('..'); + +export class FileExistsError extends Error { + constructor(name) { + super(`File "${name}" already exists`); + this.status = 409; + } +} + +export async function ensureDirs() { + await fs.mkdir(SYNC_DIR, { recursive: true }); +} + +export async function readJson(file, fallback) { + try { + return JSON.parse(await fs.readFile(file, 'utf8')); + } catch { + return fallback; + } +} + +// Write atomically: temp file then rename. +export async function writeAtomic(file, text) { + const tmp = `${file}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tmp, text); + await fs.rename(tmp, file); +} + +const filePath = (name) => path.join(SYNC_DIR, name); + +export async function listFiles({ prefix = '', name = '' } = {}) { + const entries = await fs.readdir(SYNC_DIR).catch(() => []); + return entries + .filter((entry) => isValidFileName(entry) && !entry.endsWith('.tmp')) + .filter((entry) => (name ? entry === name : entry.startsWith(prefix))) + .sort() + .map((entry) => ({ id: entry, name: entry })); +} + +export async function readFileText(name) { + if (!isValidFileName(name)) return null; + try { + return await fs.readFile(filePath(name), 'utf8'); + } catch { + return null; + } +} + +/** Creates a file; refuses to replace an existing one (revisions are immutable). */ +export async function createFile(name, content) { + if (!isValidFileName(name)) throw Object.assign(new Error('Invalid file name'), { status: 400 }); + if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw Object.assign(new Error('File too large'), { status: 413 }); + await ensureDirs(); + const tmp = `${filePath(name)}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tmp, content); + try { + // link() fails if the target exists: atomic create-if-absent. + await fs.link(tmp, filePath(name)); + } catch (error) { + if (error.code === 'EEXIST') throw new FileExistsError(name); + throw error; + } finally { + await fs.unlink(tmp).catch(() => {}); + } + return { id: name, name }; +} + +export async function updateFile(name, content) { + if (!isValidFileName(name)) throw Object.assign(new Error('Invalid file name'), { status: 400 }); + if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw Object.assign(new Error('File too large'), { status: 413 }); + await writeAtomic(filePath(name), content); + return { id: name, name }; +} + +export async function deleteFile(name) { + if (!isValidFileName(name)) return; + await fs.unlink(filePath(name)).catch(() => {}); +} diff --git a/src/components/dialogue/DialoguePreviewOverlay.jsx b/src/components/dialogue/DialoguePreviewOverlay.jsx index aa6175a..33f3b0d 100644 --- a/src/components/dialogue/DialoguePreviewOverlay.jsx +++ b/src/components/dialogue/DialoguePreviewOverlay.jsx @@ -76,6 +76,7 @@ export function DialoguePreviewOverlay({ edges = [], participants = [], rootDialogueId = '', + startNodeId = '', loadDialogueGraphForPreview, onStop, onNodeFocus, @@ -173,10 +174,23 @@ export function DialoguePreviewOverlay({ [getGraphRuntime] ); + // Where the preview begins in the root dialogue: the chosen node, or the Start node's first valid output. + const getRootStartRef = useCallback( + (context) => { + const wanted = String(startNodeId || '').trim(); + if (wanted) { + const graph = getGraphRuntime(safeRootDialogueId); + if (graph?.nodeMap.has(wanted)) return buildNodeRef(safeRootDialogueId, wanted); + } + return getStartPreviewNodeRef(safeRootDialogueId, context); + }, + [startNodeId, safeRootDialogueId, getGraphRuntime, getStartPreviewNodeRef] + ); + const selectedRouteNodeKeys = useMemo(() => { const route = []; const seen = new Set(); - let cursorRef = getStartPreviewNodeRef(safeRootDialogueId, scenarioContext); + let cursorRef = getRootStartRef(scenarioContext); let steps = 0; while (cursorRef && steps < MAX_PREVIEW_STEPS) { @@ -224,10 +238,10 @@ export function DialoguePreviewOverlay({ return route; }, [ - safeRootDialogueId, scenarioContext, graphCache, getStartPreviewNodeRef, + getRootStartRef, getTraversableNextNodeRefs, selectedBranches, ]); @@ -393,6 +407,8 @@ export function DialoguePreviewOverlay({ nodeRef: choiceRef, id: choiceNode.id, dialogueId: choiceRef.dialogueId, + // Action label (what the player picks) and the line the player then says. + spokenLine: String(getDialogueRowsForPreview(choiceNode)[0]?.text || '').trim(), label: choiceNode.data?.selectionTitle || choiceNode.data?.displayName || @@ -432,7 +448,12 @@ export function DialoguePreviewOverlay({ } }); - return options; + // Options that end the conversation (goodbye) go last; others keep their order. + const endsConversation = (option) => option.outcome === 'complete' || option.outcome === 'end'; + return options + .map((option, index) => ({ option, index })) + .sort((a, b) => Number(endsConversation(a.option)) - Number(endsConversation(b.option)) || a.index - b.index) + .map(({ option }) => option); }, [getGraphRuntime, buildChoiceOption] ); @@ -702,7 +723,7 @@ export function DialoguePreviewOverlay({ ); const startScenarioPreview = useCallback(() => { - const startNodeRef = getStartPreviewNodeRef(safeRootDialogueId, scenarioContext); + const startNodeRef = getRootStartRef(scenarioContext); if (!startNodeRef) { setScenarioError(t('editor.preview.invalidNoPlayablePath')); return; @@ -711,7 +732,7 @@ export function DialoguePreviewOverlay({ setScenarioReady(true); runtimeRef.current.stepCount = 0; goToNode(startNodeRef); - }, [getStartPreviewNodeRef, goToNode, safeRootDialogueId, scenarioContext, t]); + }, [getRootStartRef, goToNode, scenarioContext, t]); const preloadGraphCache = useCallback(async () => { const rootId = String(safeRootDialogueId || '').trim(); @@ -760,9 +781,9 @@ export function DialoguePreviewOverlay({ // A playback session owns timers/audio. Changing volume or callback identities // must not tear it down; StrictMode cleanup/setup must always start it afresh. const sessionCallbacksRef = useRef(null); - sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio }; + sessionCallbacksRef.current = { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, getRootStartRef, preloadGraphCache, stopAudio }; useEffect(() => { - const { clearTimer, closePreview, goToNode, getStartPreviewNodeRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current; + const { clearTimer, closePreview, goToNode, getRootStartRef, preloadGraphCache, stopAudio } = sessionCallbacksRef.current; const runtime = runtimeRef.current; sessionRef.current += 1; if (!open) { @@ -808,10 +829,7 @@ export function DialoguePreviewOverlay({ acc[rule.key] = true; return acc; }, {}); - const initialStartNodeRef = getStartPreviewNodeRef( - safeRootDialogueId, - initialScenarioContext - ); + const initialStartNodeRef = getRootStartRef(initialScenarioContext); setScenarioRules(mergedScenarioRules); setScenarioContext(initialScenarioContext); @@ -1085,6 +1103,11 @@ export function DialoguePreviewOverlay({ >
{choice.label}
+ {choice.spokenLine && choice.spokenLine !== choice.label && ( +
+ “{choice.spokenLine}” +
+ )}
{choice.outcome === 'end' && ( diff --git a/src/helpers/nameHelpers.js b/src/helpers/nameHelpers.js new file mode 100644 index 0000000..63b9b05 --- /dev/null +++ b/src/helpers/nameHelpers.js @@ -0,0 +1,34 @@ +// Names for participants, categories, decorators, conditions and projects end up as Unreal +// gameplay tags and data table row names. Allowed: letters and digits. +// No spaces, dots (the dot separates tag levels), underscores or dashes (the Unreal importer rejects them). + +export const NAME_PATTERN = /^[A-Za-z0-9]+$/; +export const NAME_RULE_TEXT = 'Use letters and numbers only (spaces are joined automatically)'; + +const capitalize = (word) => (word ? word[0].toUpperCase() + word.slice(1) : ''); + +/** "my cool name" -> "MyCoolName". Other characters are removed. */ +export function toPascalName(input) { + return String(input ?? '') + .trim() + .split(/\s+/) + .map((word) => word.replace(/[^A-Za-z0-9]/g, '')) + .filter(Boolean) + .map(capitalize) + .join(''); +} + +/** + * Used while typing. Leaves single words alone, but as soon as a space is typed the words are + * joined in PascalCase. One trailing space is kept so the next letter gets capitalised. + */ +export function toPascalNameLive(input) { + const value = String(input ?? ''); + if (!/\s/.test(value)) return value; + const joined = toPascalName(value); + return /\s$/.test(value) && joined ? `${joined} ` : joined; +} + +export function isValidName(name) { + return NAME_PATTERN.test(String(name ?? '')); +} diff --git a/src/i18n/locales/cs.json b/src/i18n/locales/cs.json index 8d2e9b8..e85bf7f 100644 --- a/src/i18n/locales/cs.json +++ b/src/i18n/locales/cs.json @@ -255,6 +255,9 @@ "focusNode": "Zaměřit node", "unsavedLeaveTitle": "Neuložené změny", "unsavedLeaveDescription": "Máš neuložené změny. Opuštěním stránky budou ztraceny. Pokračovat?" + }, + "preview": { + "startFromHere": "Náhled odtud" } }, "participants": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8b07886..c908c50 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -179,6 +179,9 @@ "recenterTooltip": "Gesamten Graph anzeigen", "startFocusTooltip": "Startknoten fokussieren" }, + "preview": { + "startFromHere": "Vorschau ab hier" + }, "nodes": { "start": "Start", "startDescription": "Einstiegspunkt des Dialogs", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ed4afcc..d146f02 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -212,6 +212,7 @@ "title": "Dialogue Preview", "start": "Preview", "startTooltip": "Start dialogue preview", + "startFromHere": "Preview from here", "stop": "Stop", "continue": "Continue", "waiting": "Waiting for next dialogue line...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d634c85..90168b1 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -253,6 +253,9 @@ "focusNode": "Ir al nodo", "unsavedLeaveTitle": "Cambios sin guardar", "unsavedLeaveDescription": "Tienes cambios sin guardar. Salir los descartará. ¿Continuar?" + }, + "preview": { + "startFromHere": "Vista previa desde aquí" } }, "participants": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1d50a74..a8850f3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -179,6 +179,9 @@ "recenterTooltip": "Afficher tout le graphe", "startFocusTooltip": "Centrer le nœud Start" }, + "preview": { + "startFromHere": "Aperçu à partir d'ici" + }, "nodes": { "start": "Début", "startDescription": "Point d'entrée du dialogue", diff --git a/src/i18n/locales/pl.json b/src/i18n/locales/pl.json index f1da136..30af21d 100644 --- a/src/i18n/locales/pl.json +++ b/src/i18n/locales/pl.json @@ -181,6 +181,9 @@ "recenterTooltip": "Pokaż cały graf", "startFocusTooltip": "Przejdź do węzła Start" }, + "preview": { + "startFromHere": "Podgląd od tego miejsca" + }, "nodes": { "start": "Start", "startDescription": "Punkt wejścia dialogu", diff --git a/src/lib/persistence/canonicalProject.js b/src/lib/persistence/canonicalProject.js index 2c8bd9c..f15f367 100644 --- a/src/lib/persistence/canonicalProject.js +++ b/src/lib/persistence/canonicalProject.js @@ -1,11 +1,12 @@ import { AUTHORING_TABLES } from './schema'; import { allocateDialogueLocalizationSlug, + normalizeLocalizedStringEntry, normalizeProjectLocalizationConfig, prepareLocalizedNodesAndEntries, validateLocalizedEntriesForDialogue, } from '@/lib/localization/stringTable'; -import { validateCategoryTree, normalizeParticipant, validateDefinition } from '@/lib/domainIntegrity'; +import { validateCategoryTree, normalizeParticipant, validateDefinition, normalizePropertyType, normalizePropertyDefault } from '@/lib/domainIntegrity'; import { isValidRowTiming } from '@/lib/rowTiming'; export const PROJECT_FORMAT = 'mountea.project.v3'; @@ -76,6 +77,17 @@ function repairLegacyLocalization(snapshot) { locale: defaultLocale, existingEntries, }); + // prepareLocalizedNodesAndEntries stamps every entry with "now". Unchanged entries keep their timestamps, otherwise + // canonicalizing a canonical snapshot again would change its hash (and a remote revision would look corrupt). + const previousByKey = new Map(existingEntries.map((entry) => [entry.key, entry])); + prepared.entries = prepared.entries.map((entry) => { + const previous = previousByKey.get(entry.key); + const stable = previous && JSON.stringify(previous.values) === JSON.stringify(entry.values) + ? { ...entry, createdAt: previous.createdAt || entry.createdAt, modifiedAt: previous.modifiedAt || entry.modifiedAt } + : entry; + // Same shape a later pass would produce (adds keyType), so canonicalizing twice is a fixed point. + return normalizeLocalizedStringEntry(stable); + }); const preparedKeys = new Set(prepared.entries.map((entry) => entry.key)); const unusedEntries = existingEntries.filter((entry) => entry.key && !preparedKeys.has(entry.key)); snapshot.nodes = (snapshot.nodes || []).filter((node) => node.dialogueId !== dialogueId).concat(prepared.nodes); @@ -154,6 +166,17 @@ export async function canonicalizeProject(input, { allowIncompleteMedia = false snapshot[table] = (input[table] || []).map((record) => ({ ...record })); } snapshot.dialogues = snapshot.dialogues.map((row) => { const next = { ...row }; for (const field of LOCAL_FIELDS) delete next[field]; return next; }); + // Legacy property types (bool, int, float and this fork's "name") are normalized here, not only while validating, + // so a snapshot hashes the same before and after validation. + for (const table of ['decorators', 'conditions']) { + snapshot[table] = snapshot[table].map((definition) => ({ + ...definition, + properties: (Array.isArray(definition.properties) ? definition.properties : []).map((property) => { + const type = normalizePropertyType(property?.type); + return { ...property, type, defaultValue: normalizePropertyDefault(type, property?.defaultValue) }; + }), + })); + } snapshot.participants = await Promise.all(snapshot.participants.map(async (participant) => { if (!participant.thumbnail) return participant; const path = `participants.${participant.id}.thumbnail`, thumbnail = participant.thumbnail; diff --git a/src/lib/runtimeConfig.js b/src/lib/runtimeConfig.js index a3c25d2..b8846bd 100644 --- a/src/lib/runtimeConfig.js +++ b/src/lib/runtimeConfig.js @@ -15,7 +15,13 @@ export function isSteamChannel() { return getDistributionChannel() === 'steam'; } +/** Build flag for the self-hosted web edition: sync goes to the bundled server (server/), with no login. */ +export function isSelfHostedSync() { + return readFlag(import.meta.env.VITE_SYNC_SERVER, false); +} + export function isGoogleSyncEnabled() { + if (isSelfHostedSync()) return false; return readFlag(import.meta.env.VITE_ENABLE_GOOGLE_SYNC, true); } diff --git a/src/lib/sync/core/revisionProtocol.js b/src/lib/sync/core/revisionProtocol.js index de96302..fe369a1 100644 --- a/src/lib/sync/core/revisionProtocol.js +++ b/src/lib/sync/core/revisionProtocol.js @@ -4,6 +4,7 @@ import { canonicalizeProject, hashProject } from '@/lib/persistence/canonicalPro import { applyProjectSnapshotAsNew } from '@/lib/sync/snapshot'; import { encryptPayload, decryptPayload } from '@/lib/sync/crypto'; import { getSyncContext } from './providerGateway'; +import { getSyncProviderConfig, isPlaintextProvider } from '@/lib/sync/providers/providerRegistry'; import { MAX_SYNC_PAYLOAD_BYTES, SYNC_CATALOG_FILE_NAME } from './constants'; export const REVISION_PREFIX = 'mountea-revision-v2--'; @@ -49,11 +50,11 @@ async function validateRevision(revision) { async function decodeRevision(text, options) { const envelope = parse(text); if (envelope.schemaVersion !== 2) throw error('UNSUPPORTED_REMOTE', 'Unsupported immutable revision protocol.'); - const revision = options.providerId === 'steam' ? envelope.revision : await decryptPayload(options.passphrase, envelope.encrypted); + const revision = isPlaintextProvider(options.providerId) ? envelope.revision : await decryptPayload(options.passphrase, envelope.encrypted); return validateRevision(revision); } async function encodeRevision(revision, options) { - const envelope = options.providerId === 'steam' ? { schemaVersion: 2, revision } : { schemaVersion: 2, encrypted: await encryptPayload(options.passphrase, revision) }; + const envelope = isPlaintextProvider(options.providerId) ? { schemaVersion: 2, revision } : { schemaVersion: 2, encrypted: await encryptPayload(options.passphrase, revision) }; const content = JSON.stringify(envelope); if (new TextEncoder().encode(content).length > MAX_SYNC_PAYLOAD_BYTES) throw error('PAYLOAD_QUOTA', 'Revision exceeds the sync payload limit; it remains pending.'); return content; @@ -96,7 +97,7 @@ function headsFor(revisions, projectId, ids) { } async function legacyRevision(text, options) { const envelope = parse(text); - const legacyPassphrase = options.providerId === 'steam' ? `auto:steam:${options.context.profileId}:v1` : options.passphrase; + const legacyPassphrase = getSyncProviderConfig(options.providerId)?.requiresPassphrase === false ? `auto:${options.providerId}:${options.context.profileId}:v1` : options.passphrase; const raw = envelope.project ? envelope : await decryptPayload(legacyPassphrase, envelope); const snapshot = await canonicalizeProject(raw); const payloadHash = await hashProject(snapshot); diff --git a/src/lib/sync/providers/providerRegistry.js b/src/lib/sync/providers/providerRegistry.js index d205a43..312e498 100644 --- a/src/lib/sync/providers/providerRegistry.js +++ b/src/lib/sync/providers/providerRegistry.js @@ -11,6 +11,14 @@ export const SYNC_PROVIDER_CONFIGS = Object.freeze({ supportsCloudSync: true, requiresPassphrase: false, }), + // Optional self-hosted server (server/index.mjs). Revisions are stored unencrypted; access control + // is expected in front of the server (reverse proxy, Cloudflare Access, ...). + server: Object.freeze({ + id: 'server', + supportsCloudSync: true, + requiresPassphrase: false, + plaintext: true, + }), }); export const SYNC_PROVIDER_IDS = Object.freeze(Object.keys(SYNC_PROVIDER_CONFIGS)); @@ -32,3 +40,8 @@ export function supportsCloudSync(providerId) { const config = getSyncProviderConfig(providerId); return Boolean(config?.supportsCloudSync); } + +/** Plaintext providers store revisions unencrypted (no passphrase). */ +export function isPlaintextProvider(providerId) { + return providerId === 'steam' || Boolean(getSyncProviderConfig(providerId)?.plaintext); +} diff --git a/src/lib/sync/providers/storageProviders.js b/src/lib/sync/providers/storageProviders.js index 102d311..e88414f 100644 --- a/src/lib/sync/providers/storageProviders.js +++ b/src/lib/sync/providers/storageProviders.js @@ -46,9 +46,71 @@ const steamStorageProvider = Object.freeze({ deleteFile: async (fileId) => await deleteSteamCloudFile(fileId), }); +const API = `${import.meta.env.BASE_URL || '/'}api/sync`.replace(/\/{2,}/g, '/'); + +async function request(path, options = {}) { + const response = await fetch(`${API}${path}`, options); + if (response.status === 404) return null; + if (!response.ok) { + throw Object.assign(new Error(`Server ${response.status} on ${path}`), { code: 'SERVER_ERROR', status: response.status }); + } + return response; +} + +// Revision and deletion files are immutable and content-addressed, so a downloaded file never has to be fetched twice. +const downloadCache = new Map(); +const IMMUTABLE_PREFIXES = ['mountea-revision-v2--', 'mountea-deletion-v2--']; +const isImmutable = (id) => IMMUTABLE_PREFIXES.some((prefix) => String(id).startsWith(prefix)); + +/** Storage contract used by the revision protocol: files are {id, name}; the id is the file name. */ +const serverStorageProvider = Object.freeze({ + id: 'server', + supportsCloudSync: true, + findFileByName: async (fileName) => { + const response = await request(`/files?name=${encodeURIComponent(fileName)}`); + const { files = [] } = response ? await response.json() : {}; + return files[0] || null; + }, + listFiles: async ({ namePrefix } = {}) => { + const response = await request(`/files${namePrefix ? `?prefix=${encodeURIComponent(namePrefix)}` : ''}`); + const { files = [] } = response ? await response.json() : {}; + return files; + }, + downloadFile: async (fileId) => { + if (downloadCache.has(fileId)) return downloadCache.get(fileId); + const response = await request(`/files/${encodeURIComponent(fileId)}`); + if (!response) throw Object.assign(new Error('Remote file not found'), { code: 'NOT_FOUND' }); + const text = await response.text(); + if (isImmutable(fileId)) downloadCache.set(fileId, text); + return text; + }, + createFile: async ({ name, content }) => { + const response = await request('/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, content }), + }); + return response.json(); + }, + updateFile: async ({ fileId, content }) => { + const response = await request(`/files/${encodeURIComponent(fileId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + downloadCache.delete(fileId); + return response.json(); + }, + deleteFile: async (fileId) => { + await request(`/files/${encodeURIComponent(fileId)}`, { method: 'DELETE' }); + downloadCache.delete(fileId); + }, +}); + const STORAGE_PROVIDERS = Object.freeze({ googleDrive: googleDriveStorageProvider, steam: steamStorageProvider, + server: serverStorageProvider, }); export function resolveSyncProviderId(providerId) { @@ -72,3 +134,13 @@ export function assertCloudSyncStorageProvider(providerId = DEFAULT_SYNC_PROVIDE } return provider; } + +/** True when the self-hosted sync server answers. */ +export async function isSyncServerReachable() { + try { + const response = await fetch(`${import.meta.env.BASE_URL || '/'}api/health`.replace(/\/{2,}/g, '/'), { cache: 'no-store' }); + return response.ok; + } catch { + return false; + } +} diff --git a/src/routes/__root.jsx b/src/routes/__root.jsx index 18b258e..7d4c569 100644 --- a/src/routes/__root.jsx +++ b/src/routes/__root.jsx @@ -19,7 +19,7 @@ import { useSteamStore } from '@/stores/steamStore'; import { useProjectStore } from '@/stores/projectStore'; import { useUIStore } from '@/stores/uiStore'; import { markUserActivity, trackActiveMinute, flushActivity, retryPendingAchievements } from '@/lib/achievements/achievementTracker'; -import { isGoogleSyncEnabled, isSteamChannel } from '@/lib/runtimeConfig'; +import { isGoogleSyncEnabled, isSelfHostedSync, isSteamChannel } from '@/lib/runtimeConfig'; import { initializeActiveProfileFromSteamStatus, subscribeProfileChanges } from '@/lib/profile/activeProfile'; import { useDialogueStore } from '@/stores/dialogueStore'; import { useCategoryStore } from '@/stores/categoryStore'; @@ -217,6 +217,15 @@ function RootComponent() { await loadAccount({ steamStatus: resolvedSteamStatus }); context.assertCurrent(); if (disposed || ticket !== request) return; + if (isSelfHostedSync() && useSyncStore.getState().status === 'connected') { + // Self-hosted edition: take what other devices or the MCP server added before the first paint, so links to those projects open. + await Promise.race([ + useSyncStore.getState().syncAllProjects({ mode: 'full', trigger: 'manual' }), + new Promise((resolve) => setTimeout(resolve, 8000)), + ]); + context.assertCurrent(); + if (disposed || ticket !== request) return; + } setIsLoading(false); } catch (error) { if (disposed || ticket !== request || error.code === 'STALE_PROFILE') return; @@ -347,7 +356,7 @@ function RootComponent() { } if (isLoading || !showContent) return; if (hasAutoSyncedRef.current) return; - const autoMode = provider === 'steam' ? 'full' : 'pull'; + const autoMode = provider === 'steam' || provider === 'server' ? 'full' : 'pull'; console.log('[sync] Auto sync after hydration', { provider, mode: autoMode }); hasAutoSyncedRef.current = true; syncAllProjects({ mode: autoMode, trigger: 'auto-hydration' }); diff --git a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx index 71a4422..e65861a 100644 --- a/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx +++ b/src/routes/projects/$projectId/dialogue/$dialogueId/index.jsx @@ -146,6 +146,17 @@ const initialEdges = []; const noop = () => {}; +// Copy/paste clipboard for nodes. Module level so it also works between dialogues. +let nodeClipboard = null; // { projectId, nodes: [], edges: [], pasteCount: 0 } + +const cloneData = (value) => { + try { + return structuredClone(value); + } catch { + return JSON.parse(JSON.stringify(value)); + } +}; + const NodeContextMenuContext = createContext({ onDeleteNode: noop, onContextMenuAction: noop, @@ -164,7 +175,7 @@ const NodeContextMenuProvider = ({ value, children }) => { const withNodeContextMenu = (NodeComponent) => { const WrappedNode = (props) => { const { t } = useTranslation(); - const { onDeleteNode, onContextMenuAction } = useNodeContextMenu(); + const { onDeleteNode, onContextMenuAction, onPreviewFromNode } = useNodeContextMenu(); const isPlaceholder = props.type === 'placeholderNode'; const canDelete = !isPlaceholder && props.id !== START_NODE_ID; @@ -196,6 +207,17 @@ const withNodeContextMenu = (NodeComponent) => { {t('common.delete')} Del + {props.id !== START_NODE_ID && ( + { + event.preventDefault(); + onContextMenuAction?.(); + onPreviewFromNode?.(props.id); + }} + > + {t('editor.preview.startFromHere')} + + )} ); @@ -341,6 +363,7 @@ function DialogueEditorPage() { const [isCascadeDeleteOpen, setIsCascadeDeleteOpen] = useState(false); const [isEdgeCascadeDeleteOpen, setIsEdgeCascadeDeleteOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [previewStartNodeId, setPreviewStartNodeId] = useState(''); const [previewActiveNodeRef, setPreviewActiveNodeRef] = useState(null); const previewOriginRef = useRef(null); const graphLoadKeyRef = useRef(''); @@ -1394,7 +1417,7 @@ function DialogueEditorPage() { setPreviewActiveNodeRef(nodeRef); }, [dialogueId]); - const handleStartPreview = useCallback(() => { + const handleStartPreview = useCallback((startFromNodeId) => { if (deviceType !== 'desktop') return; const missingRequiredNodes = getMissingRequiredNodes(); @@ -1449,6 +1472,8 @@ function DialogueEditorPage() { dialogueId, nodeId: START_NODE_ID, }; + // The toolbar button passes a click event; only a string id means "start from this node". + setPreviewStartNodeId(typeof startFromNodeId === 'string' ? startFromNodeId : ''); setIsPreviewOpen(true); }, [deviceType, dialogueId, getMissingRequiredNodes, nodes, edges, t]); @@ -2003,6 +2028,110 @@ function DialogueEditorPage() { redo(); }, [redo]); + // Copy selected nodes (multi-select with Shift-drag) and the edges between them. + const handleCopyNodes = useCallback(() => { + const picked = nodes.filter( + (n) => n.type !== 'placeholderNode' && n.id !== START_NODE_ID && (n.selected || n.id === selectedNodeId) + ); + if (picked.length === 0) return false; + const ids = new Set(picked.map((n) => n.id)); + const innerEdges = edges.filter( + (e) => ids.has(e.source) && ids.has(e.target) && !e.data?.isPlaceholder + ); + nodeClipboard = { + projectId, + nodes: picked.map((n) => ({ id: n.id, type: n.type, position: { ...n.position }, data: cloneData(n.data) })), + edges: innerEdges.map((e) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle, targetHandle: e.targetHandle })), + pasteCount: 0, + }; + return true; + }, [nodes, edges, selectedNodeId, projectId]); + + // Paste: new ids everywhere, localization keys are regenerated on save, placed with an offset. + const handlePasteNodes = useCallback(() => { + if (!nodeClipboard || nodeClipboard.nodes.length === 0) return false; + nodeClipboard.pasteCount += 1; + const offset = 40 * nodeClipboard.pasteCount; + const idMap = new Map(nodeClipboard.nodes.map((n) => [n.id, uuidv4()])); + + const newNodes = []; + const newPlaceholders = []; + const newEdges = []; + for (const source of nodeClipboard.nodes) { + const newId = idMap.get(source.id); + const data = cloneData(source.data); + delete data.displayNameKey; + delete data.selectionTitleKey; + delete data.localizationNodeToken; + if (nodeClipboard.projectId !== projectId) { + // Speakers and decorator definitions belong to the source project. + data.participant = ''; + delete data.participantId; + data.decorators = []; + if (Array.isArray(data.dialogueRows)) data.dialogueRows = data.dialogueRows.map((row) => { const next = { ...row }; delete next.participantId; return next; }); + } + if (Array.isArray(data.dialogueRows)) { + data.dialogueRows = data.dialogueRows.map((row) => { + const next = { ...row, id: uuidv4() }; + delete next.textKey; + delete next.localizationRowToken; + return next; + }); + } + if (typeof data.targetNode === 'string' && idMap.has(data.targetNode)) { + data.targetNode = idMap.get(data.targetNode); + } + newNodes.push({ + id: newId, + type: source.type, + position: { x: source.position.x + offset, y: source.position.y + offset }, + data, + selected: true, + }); + + if (getNodeDefinition(source.type)?.canHaveOutputs ?? true) { + const placeholderId = uuidv4(); + newPlaceholders.push({ + id: placeholderId, + type: 'placeholderNode', + data: { label: 'Add Node', onClick: handlePlaceholderClick, parentNodeId: newId }, + position: { x: source.position.x + offset, y: source.position.y + offset + 250 }, + }); + newEdges.push({ + id: `${newId}-${placeholderId}`, + source: newId, + target: placeholderId, + type: 'default', + style: { strokeWidth: 1, stroke: '#94a3b8', strokeDasharray: '5,5' }, + data: { isPlaceholder: true }, + }); + } + } + for (const edge of nodeClipboard.edges) { + const source = idMap.get(edge.source); + const target = idMap.get(edge.target); + newEdges.push({ + id: `${source}-${target}`, + source, + target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: 'default', + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + + const nextNodes = [...nodes.map((n) => ({ ...n, selected: false })), ...newNodes, ...newPlaceholders]; + const nextEdges = [...edges, ...newEdges]; + setNodes(nextNodes); + setEdges(nextEdges); + saveToHistory(nextNodes, nextEdges); + markUnsaved(); + setSelectedEdge(null); + setSelectedNodeId(newNodes.length === 1 ? newNodes[0].id : null); + return true; + }, [nodes, edges, projectId, setNodes, setEdges, saveToHistory, markUnsaved, handlePlaceholderClick]); + // Handle keyboard events useEffect(() => { const handleKeyDown = (event) => { @@ -2010,6 +2139,29 @@ function DialogueEditorPage() { return; } + // Copy / paste nodes (not while typing, so text copy keeps working) + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) { + const el = event.target; + const typing = el?.tagName === 'INPUT' || el?.tagName === 'TEXTAREA' || el?.isContentEditable; + const hasTextSelection = !!window.getSelection()?.toString(); + const key = event.key.toLowerCase(); + if (!typing && key === 'c' && !hasTextSelection) { + if (handleCopyNodes()) event.preventDefault(); + return; + } + if (!typing && key === 'v') { + if (handlePasteNodes()) event.preventDefault(); + return; + } + if (!typing && key === 'd') { + if (handleCopyNodes()) { + event.preventDefault(); + handlePasteNodes(); + } + return; + } + } + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); handleSave(); @@ -2072,6 +2224,8 @@ function DialogueEditorPage() { handleSave, handleUndo, handleRedo, + handleCopyNodes, + handlePasteNodes, isPreviewOpen, ]); @@ -2345,8 +2499,9 @@ function DialogueEditorPage() { () => ({ onDeleteNode: deleteNodeById, onContextMenuAction: () => { lastContextMenuActionRef.current = Date.now(); }, + onPreviewFromNode: (nodeId) => handleStartPreview(String(nodeId)), }), - [deleteNodeById] + [deleteNodeById, handleStartPreview] ); if (!dialogue || !project) { @@ -2499,6 +2654,7 @@ function DialogueEditorPage() { edges={edges} participants={participants} rootDialogueId={dialogueId} + startNodeId={previewStartNodeId} loadDialogueGraphForPreview={loadDialogueGraphForPreview} onStop={handlePreviewStop} onNodeFocus={handlePreviewNodeFocus} diff --git a/src/stores/syncStore.js b/src/stores/syncStore.js index 9849477..d23a719 100644 --- a/src/stores/syncStore.js +++ b/src/stores/syncStore.js @@ -23,6 +23,8 @@ import { supportsCloudSync, } from '@/lib/sync/providers/providerRegistry'; import { buildProfileScopedKey, getActiveProfileId } from '@/lib/profile/activeProfile'; +import { isSelfHostedSync } from '@/lib/runtimeConfig'; +import { isSyncServerReachable } from '@/lib/sync/providers/storageProviders'; const SYNC_STORAGE_KEY = 'mountea-dialoguer-sync'; const DEFAULT_PROVIDER_INPUT = Object.freeze({ @@ -263,6 +265,12 @@ export const useSyncStore = create( setSyncMode: (value) => set({ syncMode: value }), loadAccount: async (options = {}) => { + if (isSelfHostedSync()) { + // Self-hosted edition: there is nothing to log in to; access control sits in front of the server. + const reachable = await isSyncServerReachable(); + set({ provider: 'server', status: reachable ? 'connected' : 'disconnected', syncMode: 'full', error: null }); + return; + } const context = options.context || await getRepositoryContext(); const capability = await credentialCapabilities(); context.assertCurrent(); diff --git a/tests/e2e/autosave.spec.js b/tests/e2e/autosave.spec.js new file mode 100644 index 0000000..2924fba --- /dev/null +++ b/tests/e2e/autosave.spec.js @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test'; +import { seedLocalState, openDashboard, createProject, uniqueToken } from './helpers/appHarness.js'; + +// Runs against the production server: node server/index.mjs (PLAYWRIGHT_BASE_URL=http://127.0.0.1:8088) +test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'needs the production server (PLAYWRIGHT_BASE_URL)'); + +async function revisionNames(request, baseURL) { + const res = await request.get(`${baseURL}/api/sync/files?prefix=mountea-revision-v2--`); + const { files } = await res.json(); + const names = []; + for (const file of files) { + const text = await (await request.get(`${baseURL}/api/sync/files/${encodeURIComponent(file.id)}`)).text(); + const revision = JSON.parse(text).revision; + if (revision?.snapshot?.project?.name) names.push(revision.snapshot.project.name); + } + return names; +} + +test('a new project is autosaved to the server and restored in a fresh browser', async ({ browser, baseURL }) => { + const name = uniqueToken('Autosave'); + + const first = await browser.newContext(); + const page = await first.newPage(); + await seedLocalState(page); + await openDashboard(page); + await createProject(page, name); + + await expect + .poll(async () => (await revisionNames(page.request, baseURL)).includes(name), { timeout: 20000 }) + .toBe(true); + await first.close(); + + // A brand new browser profile has no local data. It must get the project from the server. + const second = await browser.newContext(); + const page2 = await second.newPage(); + await seedLocalState(page2); + await page2.goto('/#/'); + await expect(page2.getByText(name).locator('visible=true').first()).toBeVisible({ timeout: 30000 }); + await second.close(); +}); diff --git a/tests/e2e/canonical-idempotence.spec.js b/tests/e2e/canonical-idempotence.spec.js new file mode 100644 index 0000000..2e08e1e --- /dev/null +++ b/tests/e2e/canonical-idempotence.spec.js @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { openModuleHarness } from './helpers/moduleHarness'; + +test.beforeEach(async ({ page }) => { await openModuleHarness(page); }); + +// A remote revision is accepted only if canonicalizing its snapshot reproduces its payload hash. +// Localized entries are stamped while canonicalizing, so the result must not depend on the clock. +test('canonicalizing a canonical snapshot keeps its hash', async ({ page }) => { + const result = await page.evaluate(async () => { + const { canonicalizeProject, hashProject } = await import('/src/lib/persistence/canonicalProject.js'); + const START = '00000000-0000-0000-0000-000000000001', stamp = '2026-01-02T03:04:05.000Z'; + const first = await canonicalizeProject({ + project: { id: 'p1', name: 'P', createdAt: stamp, modifiedAt: stamp, localization: { defaultLocale: 'en', supportedLocales: ['en'] } }, + dialogues: [{ id: 'd1', projectId: 'p1', name: 'D', createdAt: stamp, modifiedAt: stamp }], + nodes: [ + { id: START, dialogueId: 'd1', type: 'startNode', position: { x: 0, y: 0 }, data: { displayName: 'Start' } }, + { id: 'n1', dialogueId: 'd1', type: 'leadNode', position: { x: 0, y: 100 }, data: { displayName: 'Hello', dialogueRows: [{ id: 'r1', text: 'Hi there', duration: 3 }] } }, + ], + edges: [{ id: 'e1', dialogueId: 'd1', source: START, target: 'n1' }], + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + const second = await canonicalizeProject(first); + const changed = structuredClone(first); + const entry = changed.localizedStrings.find((item) => item.values.en === 'Hello'); + entry.values.en = 'Hello again'; + await new Promise((resolve) => setTimeout(resolve, 25)); + const refreshed = await canonicalizeProject(changed); + return { + entries: first.localizedStrings.length, + stable: await hashProject(second) === await hashProject(first), + refreshed: refreshed.localizedStrings.find((item) => item.key === entry.key).values.en, + }; + }); + expect(result.entries).toBeGreaterThan(0); + expect(result.stable).toBe(true); + expect(result.refreshed).toBe('Hello again'); +}); + +test('legacy property types are normalized before hashing, so validation does not change the snapshot', async ({ page }) => { + const result = await page.evaluate(async () => { + const { canonicalizeProject, hashProject, validateCanonicalProject } = await import('/src/lib/persistence/canonicalProject.js'); + const stamp = '2026-01-02T03:04:05.000Z'; + const snapshot = await canonicalizeProject({ + project: { id: 'p2', name: 'P', createdAt: stamp, modifiedAt: stamp }, + decorators: [{ id: 'dec1', projectId: 'p2', name: 'Item', properties: [{ name: 'on', type: 'bool', defaultValue: 'true' }, { name: 'count', type: 'int', defaultValue: 2 }] }], + }); + const before = await hashProject(snapshot); + const problems = validateCanonicalProject(snapshot, { localization: false }); + return { types: snapshot.decorators[0].properties.map((property) => property.type), flag: snapshot.decorators[0].properties[0].defaultValue, problems, unchanged: before === await hashProject(snapshot) }; + }); + expect(result.types).toEqual(['boolean', 'number']); + expect(result.flag).toBe(true); + expect(result.problems).toEqual([]); + expect(result.unchanged).toBe(true); +}); diff --git a/tests/e2e/copypaste.spec.js b/tests/e2e/copypaste.spec.js new file mode 100644 index 0000000..c64fd36 --- /dev/null +++ b/tests/e2e/copypaste.spec.js @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; +import { seedLocalState, uniqueToken, openDashboard, createProject, openDialoguesSection, createDialogue } from './helpers/appHarness'; + +test('nodes can be copied and pasted with the keyboard', async ({ page }) => { + await seedLocalState(page); + await openDashboard(page); + await createProject(page, uniqueToken('proj')); + await openDialoguesSection(page); + await createDialogue(page, uniqueToken('dlg')); + + const toolbar = page.locator('[data-tour="node-toolbar"]'); + await toolbar.locator('button.cursor-move').first().click(); + + const nodes = page.locator('.react-flow__node-leadNode'); + await expect(nodes).toHaveCount(1); + await nodes.first().click(); + + await page.keyboard.press('Control+c'); + await page.keyboard.press('Control+v'); + await expect(nodes).toHaveCount(2); + + await page.keyboard.press('Control+v'); + await expect(nodes).toHaveCount(3); + + // Duplicate (Ctrl+D) the selected node. + await page.keyboard.press('Control+d'); + await expect(nodes).toHaveCount(4); + + // Saving must not hit a localization key collision. + await page.keyboard.press('Control+s'); + await page.waitForTimeout(1500); + await expect(page.getByText(/Failed to Save/i)).toHaveCount(0); + await page.reload(); + await expect(page.locator('.react-flow__node-leadNode')).toHaveCount(4); +}); diff --git a/tests/e2e/helpers/appHarness.js b/tests/e2e/helpers/appHarness.js index 4047ab9..d85e35e 100644 --- a/tests/e2e/helpers/appHarness.js +++ b/tests/e2e/helpers/appHarness.js @@ -64,3 +64,61 @@ export async function openDialogueSettings(page) { await expect(page).toHaveURL(/#\/projects\/[^/]+\/dialogue\/[^/]+\/settings\/?$/); await expect(page.getByRole('heading', { name: /dialogue settings/i }).first()).toBeVisible(); } + +export const START_NODE_ID = '00000000-0000-0000-0000-000000000001'; + +// Builds the graph of the currently open dialogue through the app's own stores (the same path the editor +// uses), then reloads the editor. Needs the Vite dev server: modules are imported by path, so this does not +// work against the production bundle. +// `nodes`: [{ id, type, data }] (text is plain), `edges`: [[source, target]] where "start" is the Start node. +// Every node gets participant "Tester" unless it sets its own. +export async function seedGraph(page, { nodes = [], edges = [] }) { + await page.evaluate( + async ({ nodes: seedNodes, edges: seedEdges, startId }) => { + const [, projectId, dialogueId] = window.location.hash.match(/projects\/([^/]+)\/dialogue\/([^/?]+)/); + const { useCategoryStore } = await import('/src/stores/categoryStore.js'); + const { useParticipantStore } = await import('/src/stores/participantStore.js'); + const { useDialogueStore } = await import('/src/stores/dialogueStore.js'); + const category = await useCategoryStore.getState().createCategory({ projectId, name: 'Folk' }); + const participant = await useParticipantStore.getState().createParticipant({ projectId, name: 'Tester', category: category.name, categoryId: category.id }); + const toNode = (node, index) => ({ + position: { x: 300, y: 150 * (index + 1) }, + ...node, + data: { + label: node.id, + displayName: node.id, + participant: participant.name, + participantId: participant.id, + decorators: [], + hasAudio: false, + ...node.data, + }, + }); + const start = { + id: startId, + type: 'startNode', + position: { x: 0, y: 0 }, + deletable: false, + data: { label: 'Dialogue entry point', displayName: 'Start', decorators: [], dialogueRows: [], hasAudio: false }, + }; + const edges = seedEdges.map(([source, target]) => ({ + id: `${source}-${target}`, + source: source === 'start' ? startId : source, + target, + type: 'conditionEdge', + data: { conditions: { mode: 'all', rules: [] } }, + })); + await useDialogueStore.getState().saveDialogueGraph(dialogueId, [start, ...seedNodes.map(toNode)], edges, { x: 0, y: 0, zoom: 1 }, {}); + }, + { nodes, edges, startId: START_NODE_ID } + ); + await page.reload(); + await expect(page.locator('[data-tour="canvas"]')).toBeVisible(); + // The editor shows a loading overlay for a moment; wait until the seeded graph is on screen. + await expect(page.locator('.react-flow__node').first()).toBeVisible(); + await expect(page.getByText(/preparing dialogue graph/i)).toHaveCount(0, { timeout: 15000 }); +} + +export function textRow(id, text, duration = 5) { + return { id, text, duration }; +} diff --git a/tests/e2e/helpers/mcpClient.js b/tests/e2e/helpers/mcpClient.js new file mode 100644 index 0000000..589be61 --- /dev/null +++ b/tests/e2e/helpers/mcpClient.js @@ -0,0 +1,41 @@ +// Tiny MCP client for e2e tests. Needs MCP_URL (e.g. http://127.0.0.1:8089) and MCP_TOKEN in the environment. +export const mcpEnabled = Boolean(process.env.MCP_URL && process.env.MCP_TOKEN); + +let id = 1; +export async function mcpTool(name, args = {}) { + const res = await fetch(`${process.env.MCP_URL}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.MCP_TOKEN}` }, + body: JSON.stringify({ jsonrpc: '2.0', id: id++, method: 'tools/call', params: { name, arguments: args } }), + }); + const body = await res.json(); + const text = body.result.content[0].text; + if (body.result.isError) throw new Error(`${name}: ${text}`); + return JSON.parse(text); +} + +/** Builds a small project: one NPC greeting, two player options, a return node and a complete node. */ +export async function buildSampleProject(suffix) { + const { projectId } = await mcpTool('create_project', { name: `McpProject${suffix}` }); + await mcpTool('create_category', { projectId, name: 'Folk' }); + await mcpTool('create_participant', { projectId, name: 'Bob', category: 'Folk' }); + const { dialogueId } = await mcpTool('create_dialogue', { projectId, name: 'Greeting' }); + await mcpTool('set_graph', { + projectId, + dialogueId, + mode: 'replace', + nodes: [ + { ref: 'hello', type: 'leadNode', displayName: 'Hello', participant: 'Bob', rows: [{ text: 'Well met, traveller.', duration: 8 }] }, + { ref: 'asky', type: 'answerNode', displayName: 'AskCurse', selectionTitle: 'Ask about the curse', participant: 'Bob', rows: ['Tell me about the curse.'] }, + { ref: 'bye', type: 'answerNode', displayName: 'Leave', selectionTitle: 'Say goodbye', participant: 'Bob', rows: ['I must go.'] }, + { ref: 'end', type: 'completeNode', displayName: 'End', participant: 'Bob' }, + ], + edges: [ + { source: 'start', target: 'hello' }, + { source: 'hello', target: 'asky' }, + { source: 'hello', target: 'bye' }, + { source: 'bye', target: 'end' }, + ], + }); + return { projectId, dialogueId, name: `McpProject${suffix}` }; +} diff --git a/tests/e2e/mcp.spec.js b/tests/e2e/mcp.spec.js new file mode 100644 index 0000000..3d08961 --- /dev/null +++ b/tests/e2e/mcp.spec.js @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; +import JSZip from 'jszip'; +import { readFile } from 'node:fs/promises'; +import { seedLocalState, uniqueToken, openDialogueSettings } from './helpers/appHarness'; +import { mcpEnabled, mcpTool, buildSampleProject } from './helpers/mcpClient'; + +// Runs against the production server and the MCP server sharing one DATA_DIR: +// PORT=8088 DATA_DIR=/tmp/td node server/index.mjs +// MCP_PORT=8089 MCP_TOKEN=... DATA_DIR=/tmp/td node --import ./mcp/register.mjs mcp/server.mjs +// MCP_URL=http://127.0.0.1:8089 MCP_TOKEN=... PLAYWRIGHT_BASE_URL=http://127.0.0.1:8088 npx playwright test mcp +test.skip(!mcpEnabled, 'MCP_URL and MCP_TOKEN not set'); + +const previewLine = (page, text) => page.locator('p.whitespace-pre-wrap', { hasText: text }); + +async function openDialogue(page, { projectId, dialogueId }) { + await seedLocalState(page); + await page.goto(`/#/projects/${projectId}/dialogue/${dialogueId}`); + await expect(page.locator('[data-tour="canvas"]')).toBeVisible({ timeout: 30000 }); + await expect(page.locator('.react-flow__node').first()).toBeVisible(); + await expect(page.getByText(/preparing dialogue graph/i)).toHaveCount(0, { timeout: 15000 }); +} + +test('a dialogue built through MCP opens, previews and exports in the app', async ({ page }) => { + const sample = await buildSampleProject(uniqueToken('a')); + await openDialogue(page, sample); + + await expect(page.locator('.react-flow__node')).toHaveCount(5); + await expect(page.locator('.react-flow__edge')).toHaveCount(4); + + await page.getByRole('button', { name: /^preview$/i }).first().click(); + await expect(previewLine(page, 'Well met, traveller.')).toBeVisible(); + await page.keyboard.press('Escape'); + + await openDialogueSettings(page); + const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: /export dialogue/i }).click()]); + const zip = await JSZip.loadAsync(await readFile(await download.path())); + const manifest = JSON.parse(await zip.file('manifest.json').async('string')); + expect(manifest.format).toBe('mountea.archive.v3'); + const readJson = async (archive, path) => JSON.parse(await archive.file(path).async('string')); + const strings = []; + for (const record of manifest.records.filter((entry) => entry.table === 'localizedStrings')) strings.push(await readJson(zip, record.path)); + const valuesOf = (field) => strings.filter((entry) => entry.field === field).map((entry) => entry.values.en); + expect(valuesOf('selectionTitle')).toEqual(expect.arrayContaining(['Ask about the curse', 'Say goodbye'])); + expect(valuesOf('displayName')).toEqual(expect.arrayContaining(['Hello', 'AskCurse', 'Leave'])); + + // The MCP export must match the UI export record for record. + const viaMcp = await mcpTool('export_dialogue', { projectId: sample.projectId, dialogueId: sample.dialogueId, inline: true }); + const mcpZip = await JSZip.loadAsync(Buffer.from(viaMcp.base64, 'base64')); + const mcpManifest = await readJson(mcpZip, 'manifest.json'); + expect(mcpManifest.records).toEqual(manifest.records); + for (const { path } of manifest.records) expect(await readJson(mcpZip, path), path).toEqual(await readJson(zip, path)); + expect(await readJson(mcpZip, 'project.json')).toEqual(await readJson(zip, 'project.json')); +}); + +const syncNow = (page) => page.evaluate(() => window.dispatchEvent(new Event('online'))); +const addedByClaude = (sample) => + mcpTool('add_node', { projectId: sample.projectId, dialogueId: sample.dialogueId, type: 'leadNode', displayName: 'AddedByClaude', participant: 'Bob', rows: ['Hello from MCP'] }); + +test('competing saves are kept as a conflict, never silently overwritten', async ({ page }) => { + const sample = await buildSampleProject(uniqueToken('d')); + await openDialogue(page, sample); + + // Save locally, then MCP writes before the tab has synced. + await page.locator('[data-tour="node-toolbar"] button.cursor-move').first().click(); + await expect(page.locator('.react-flow__node')).toHaveCount(6); + await page.keyboard.press('Control+s'); + await addedByClaude(sample); + await syncNow(page); + + await expect(page.getByRole('complementary', { name: /synchronization conflicts/i })).toBeVisible({ timeout: 40000 }); + // MCP's version is still the server's view of the project, intact. + const g = await mcpTool('get_dialogue', { projectId: sample.projectId, dialogueId: sample.dialogueId }).catch((error) => ({ error: error.message })); + expect(JSON.stringify(g)).toMatch(/divergent|AddedByClaude/); +}); diff --git a/tests/e2e/previewFromNode.spec.js b/tests/e2e/previewFromNode.spec.js new file mode 100644 index 0000000..f5399aa --- /dev/null +++ b/tests/e2e/previewFromNode.spec.js @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import { + seedLocalState, + uniqueToken, + openDashboard, + createProject, + openDialoguesSection, + createDialogue, + seedGraph, + textRow, +} from './helpers/appHarness'; + +// The preview line is the only whitespace-pre-wrap paragraph; the canvas nodes show the same text too. +const previewLine = (page, text) => page.locator('p.whitespace-pre-wrap', { hasText: text }); + +async function openGraph(page) { + await seedLocalState(page); + await openDashboard(page); + await createProject(page, uniqueToken('proj')); + await openDialoguesSection(page); + await createDialogue(page, uniqueToken('dlg')); + await seedGraph(page, { + nodes: [ + { id: 'npc-one', type: 'leadNode', data: { dialogueRows: [textRow('r1', 'ALPHA first line')] } }, + { id: 'npc-two', type: 'leadNode', data: { dialogueRows: [textRow('r2', 'BRAVO second line')] } }, + ], + edges: [ + ['start', 'npc-one'], + ['npc-one', 'npc-two'], + ], + }); + await expect(page.locator('.react-flow__node-leadNode')).toHaveCount(2); +} + +test('node context menu offers "Preview from here"', async ({ page }) => { + await openGraph(page); + await page.locator('.react-flow__node[data-id="npc-two"]').click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: /preview from here/i })).toBeVisible(); +}); + +test('"Preview from here" starts the preview at the chosen node', async ({ page }) => { + await openGraph(page); + await page.locator('.react-flow__node[data-id="npc-two"]').click({ button: 'right' }); + await page.getByRole('menuitem', { name: /preview from here/i }).click(); + + await expect(previewLine(page, 'BRAVO second line')).toBeVisible(); + await expect(previewLine(page, 'ALPHA first line')).toHaveCount(0); +}); + +test('regular Preview starts at the first node after Start', async ({ page }) => { + await openGraph(page); + await page.getByRole('button', { name: /^preview$/i }).first().click(); + await expect(previewLine(page, 'ALPHA first line')).toBeVisible(); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..5e52677 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +// Only the MCP server tests use Vitest; the application is covered by the Playwright specs in tests/e2e. +export default defineConfig({ + resolve: { + alias: { '@': path.resolve(__dirname, './src') }, + }, + test: { + globals: true, + include: ['mcp/**/*.test.js'], + }, +});