graphite-editor-mcp
# graphite-editor-mcp
> **A programmatic graphics engine boundary for AI agents.** Deterministic, node-graph-based, agent-drivable: agents author documents as data, a headless CLI renders them, assets are reproducible and versionable. The MCP server is the stable boundary that makes this usable by agents and automation: it wraps data-driving, never GUI-driving, and it exists to be boring — every call returns a validated artifact or a precise error. Print/DTP finishing is deliberately out of scope; Graphite owns procedural/generative asset production.
## What this is — and is not
**It is:** an MCP server (stdio) exposing four tools over the pinned `graphene-cli` binary. Agents author documents as data — either template + parameters or inline legacy `.graphite` JSON — and receive validated, hashed artifacts with full provenance. Nothing here drives a GUI.
**It is not:** a print pipeline. `graphene-cli` writes `.png`, `.jpg`, `.jpeg`, `.gif` and `.svg` only — **PDF is explicitly rejected upstream**. It is also not an upstream Graphite fork: the engine is pinned at a verified commit with zero source modifications (see `docs/ENGINE-PIN.md`).
## Architecture (one paragraph)
Tools → builder/templates → runner → pinned CLI: each MCP tool validates its inputs, produces a complete legacy `.graphite` document (via the template factories in `src/templates.ts` or an inline document materialized under `tmp/mcp-inline/`), and hands a render spec to `GraphiteRunner` (`src/runner.ts`), which spawns the pinned `graphene-cli` binary, captures its output tails, enforces a per-render timeout, and — because **every successful export writes a valid artifact and THEN dies with SIGSEGV** — validates the artifact unconditionally (`src/validator.ts`: magic bytes + structure + size floors) and quarantines the abnormal exit instead of trusting exit codes. Failures are typed (`src/errors.ts`) with stable codes and precise diagnostics; the MCP layer turns every failure into an `isError` result carrying the code.
## Quickstart
Prerequisites:
- **Node ≥ 22** (see `package.json` `engines`).
- **Rust toolchain** (`cargo` on PATH, or at `~/.cargo/bin`) — for building the pinned engine.
- **git** — for the shallow engine fetch.
```bash
git clone https://github.com/ktheunlikedoll/graphite-editor-mcp.git
cd graphite-editor-mcp
npm install
bash scripts/fetch-engine.sh # shallow-fetch the pinned upstream SHA into engine/ and build graphene-cli
npm run build # tsc → dist/src/*.js
npm test # optional; the real-CLI test tiers run when the binary is present
```
`scripts/fetch-engine.sh` shallow-fetches the exact pinned upstream commit into `engine/Graphite/` (gitignored — never committed) and builds the debug `graphene-cli`. It is idempotent: if `engine/Graphite` is already at the pinned commit, it skips the fetch. First build takes a while. Reference build facts — commit SHA, binary sha256, toolchain — are recorded in **`docs/ENGINE-PIN.md`**.
### The CLI path: `GRAPHITE_CLI_PATH`
Resolution order (`src/paths.ts`):
1. `GRAPHITE_CLI_PATH` env var — used **exclusively** when set. A broken value fails precisely on that value; it never silently falls back.
2. Default: `<projectRoot>/engine/Graphite/target/debug/graphene-cli` (the pinned debug build).
3. Otherwise: `CLI_NOT_FOUND` error listing exactly what was searched.
## Tool contracts
Four tools, stdio transport, official MCP SDK, zod-validated inputs. Every result is one JSON text block. Every failure is an `isError: true` result whose text is a JSON error envelope:
```json
{
"code": "SPEC_INVALID",
"message": "output format \"png\" requires an explicit size: provide width AND height with BOTH dimensions (got width=800, height=undefined). Rationale: graphene-cli silently falls back to a 1x1 default viewport when a dimension is missing, so Graphite rejects incomplete sizes outright. (SVG is the only format that may omit size.)",
"name": "SpecInvalidError"
}
```
### Error codes
| Code | Class | When |
|---|---|---|
| `CLI_NOT_FOUND` | `CliNotFoundError` | No usable binary (missing, not a file, not executable). Carries `searchedPaths`, `reason`. |
| `DOC_INVALID` | `DocInvalidError` | The engine refused the document (compile failed) or it cannot be read. Carries `documentPath`, `detail`, `exitCode`, `signal`, `stderrTail`. |
| `RENDER_FAILED` | `RenderError` | The CLI failed in a way that is NOT the SIGSEGV-after-write case. Carries `command`, `exitCode`, `signal`, `stdoutTail`, `stderrTail`. |
| `ARTIFACT_INVALID` | `ArtifactInvalidError` | The CLI produced (or claimed to produce) an artifact that failed validation. Carries `artifactPath`, `reason`, `exitCode`, `signal`, `stderrTail`. |
| `TIMEOUT` | `TimeoutError` | Render exceeded its time budget (default 120 s) and was killed. Carries `command`, `timeoutMs`, `stderrTail`. |
| `SPEC_INVALID` | `SpecInvalidError` | Malformed request before/without any process run: unknown template id, bad params, incomplete raster size, unsupported GIF timing, bad `outputName`, variant-count bounds. |
| `UNEXPECTED` | — | Any non-Graphite error; message only, never a stack trace. |
### `graphite_render`
Renders a document to a validated artifact.
| Input | Type | Notes |
|---|---|---|
| `templateId` | `string?` | One of the four template ids. **Either `templateId` or `document` — never both.** |
| `params` | `object?` | Template parameters; defaults to the template's `DEFAULT_PARAMS` when omitted. Validated by the template factory. |
| `document` | `object \| string?` | Inline legacy `.graphite` JSON (parsed object or raw JSON string). Materialized under `tmp/mcp-inline/` before rendering. |
| `output.format` | `"png" \| "svg" \| "jpg" \| "gif"` | Required. |
| `output.width` / `output.height` | `int?` | **Both required together for raster formats** (png/jpg/gif); a width-only request is rejected with `SPEC_INVALID` (the CLI would silently render a 1x1 default viewport). SVG may omit both. |
| `output.outputName` | `string?` | Base name WITHOUT extension — the format extension is appended (`hero` + png → `exports/hero.png`). Defaults to a timestamped, collision-safe name (`<templateId or "render">-<ISO timestamp>-<8 hex chars>`). |
| `output.gif` | `{ mode: "frames", fps, frames } \| { mode: "duration", duration }` | Required for `.gif`. |
Output path: `<projectRoot>/exports/<name>` (directory created on demand; the runner deletes stale artifacts of the same path before rendering so validation is always about THIS render).
Successful result (real output, template-driven duotone with default params):
```json
{
"artifactPath": "<project-root>/exports/readme-example.png",
"format": "png",
"byteSize": 1154138,
"sha256": "93ef9b199f1e166cb95d26ba584f7038745a701ff18c9195e4ffc3eb23060b75",
"width": 1000,
"height": 500,
"validation": {
"valid": true,
"format": "png",
"byteSize": 1154138,
"path": "<project-root>/exports/readme-example.png",
"width": 1000,
"height": 500
},
"quarantine": {
"exitCode": null,
"signal": "SIGSEGV",
"quarantinedAfterWrite": true,
"stderrTail": ""
},
"renderMs": 1502,
"exitCode": null,
"signal": "SIGSEGV",
"command": "graphene-cli export …/tmp/mcp-inline/render-….graphite --output …/exports/readme-example.png --width 1000 --height 500",
"documentPath": "<project-root>/tmp/mcp-inline/render-….graphite",
"templateId": "duotone-image",
"params": { "width": 1000, "height": 500, "seed": 0, "scale": 35, "dark": { "red": 0.02, "green": 0.02, "blue": 0.12, "alpha": 1 }, "light": { "red": 1, "green": 0.3, "blue": 0.03, "alpha": 1 }, "reverse": false }
}
```
`templateId`/`params` echo only on template-driven renders (echoing the parameters actually used — defaults included). The render is NOT compiled separately first: the export itself surfaces compile errors (`DOC_INVALID`), so every call spawns exactly one CLI process.
### `graphite_list_nodes`
No required inputs; optional `filter` (case-sensitive substring). Returns `{ "count": <n>, "nodes": ["…", …] }` from `graphene-cli list-node-identifiers` — **324 identifiers** on the pinned build. The unfiltered list is cached in memory after the first success (process lifetime; the pin cannot change under a running server).
```json
{
"count": 1,
"nodes": ["raster_nodes::adjustments::GradientMapNode"]
}
```
### `graphite_validate_doc`
Structural validation of a document WITHOUT rendering. Pass either `document` (inline object or JSON string) or `documentPath` (existing file) — never both. Optional `compileCheck: true` adds the engine's own `compile` verdict (default `false` — fast and offline). **Validation results are data:** an invalid document returns `valid: false` with precise reasons; it is not an error result.
```json
{
"valid": false,
"structural": {
"checks": {
"parsed": true, "hasNetworkInterface": true, "hasNetwork": true,
"hasNodesArray": true, "hasExportsArray": true,
"nodeEntriesWellFormed": true, "exportsResolveToNodes": false
},
"nodeCount": 4,
"reasons": ["export references node id 999999, which does not exist in network.nodes"]
}
}
```
With `compileCheck: true` on a structurally-valid document:
```json
{
"valid": true,
"structural": { "checks": { "parsed": true, "hasNetworkInterface": true, "hasNetwork": true, "hasNodesArray": true, "hasExportsArray": true, "nodeEntriesWellFormed": true, "exportsResolveToNodes": true }, "nodeCount": 4, "reasons": [] },
"compile": { "ok": true, "stderrTail": "" },
"documentPath": "<project-root>/tmp/mcp-inline/validate-….graphite"
}
```
Compile runs only after structural validation passes; when structural validation fails, `compile` is reported as `{ "ok": false, "reason": "not run: structural validation failed" }` (no wasted spawn). `documentPath` echoes the on-disk path (the materialized copy for inline documents). A failed engine compile sets `compile.ok: false` with the engine's `stderrTail`.
### `graphite_render_variants`
Sequential batch renderer (v1 doctrine: never parallel — one GPU-heavy child at a time).
| Input | Type | Notes |
|---|---|---|
| `templateId` | `string` | Required. |
| `variants` | `params[]` | **2..20** full parameter objects; each validated independently. |
| `format` | `"png" \| "svg" \| "jpg" \| "gif"` | Required. |
| `width` / `height` | `int` | Required — the render size for every variant (pass the same values inside each variant's params). |
| `outputDir` | `string?` | Default `<projectRoot>/exports/`; relative paths resolve against the project root. |
Outputs: `<templateId>-<index>.<format>` (index = position in `variants`, 0-based) under the output dir. Each variant goes through the full render + validate pipeline; a failing variant is reported per-item and does NOT abort the batch — the call is `isError` only when **every** variant fails.
Result: `{ "count": 3, "results": [ …same shape as graphite_render results… ] }`; a failed item looks like `{ "templateId": "duotone-image", "params": { … }, "error": { "code": "SPEC_INVALID", "message": "…" } }`.
## Templates
| Id | Produces | Params | Provenance |
|---|---|---|---|
| `solid-background` | Full-bleed solid color (EmptyImage route) or two-color linear gradient (proven Rectangle→Fill vector route) | `variant: 'solid' \| 'gradient'; width; height; color` (solid) \| `from, to` (gradient) — linear-RGB channels 0..1 | working demo gradient route |
| `pattern-background` | Procedural noise-pattern background (NoisePatternNode, clip disabled → full-bleed) | `width; height; seed (u32); scale` | working demo — full 16-input serialization |
| `text-on-background` | Fallback-font text (TextNode → TextToVector → Fill) over a solid background | `width; height; background; text; textColor; fontSize?` (default 48) | executed TextNode chain with proven placement transform |
| `duotone-image` | Procedural noise mapped through a two-color gradient ramp (GradientMap duotone) | `width; height; seed (u32); scale; dark, light; reverse?` | demo-proven NoisePattern → GradientMap chain |
Default parameters per template live in `src/templates.ts` (`DEFAULT_PARAMS`) and are exactly the committed sample outputs in `templates/*.graphite`. Agents never hand-write wrapper JSON.
## The SIGSEGV quarantine — why every render reports `quarantinedAfterWrite`
Ground truth on the pinned engine build: **every successful `graphene-cli export` writes a complete, valid artifact and THEN dies with SIGSEGV (exit 139)** — reproducibly (likely the detached GPU-poll thread in `graphene-cli/src/main.rs`).
The runner therefore never trusts exit codes: it validates the artifact unconditionally (existence, non-trivial size, magic bytes + structure). When the CLI crashed after writing a valid artifact, the render **succeeds** and carries quarantine metadata `{ exitCode, signal, quarantinedAfterWrite: true, stderrTail }`. This is normal and expected — a successful tool result with `quarantine.quarantinedAfterWrite: true` is the pinned engine behaving exactly as measured. If a future engine build stops crashing, the field flips to `false` (exit 0 + valid artifact). A crash that left a broken artifact is NOT quarantined: it is an `ARTIFACT_INVALID` error.
## Determinism
Same spec rendered twice → byte-identical output. Evidence: the Phase-3 suite asserts pixel-identical renders for all four templates (`tests/templates-e2e.test.ts`, `determinism: … pixel-identical`), and every tool result carries the artifact `sha256`, so any two renders of the same document can be compared by hash. Assets are reproducible and diffable — the point of the whole boundary.
## Engine pin & support statement
- **Pinned engine:** upstream Graphite (`GraphiteEditor/Graphite`) at commit `d7ae6029e0c1818d81a13b0389ef1808496a715b`, fetched shallowly and built by `scripts/fetch-engine.sh`. Build facts and the reference binary sha256: `docs/ENGINE-PIN.md`.
- **Why a pin:** upstream is a fast-churning alpha. The pin exists for **reproducibility** — the node registry (324 identifiers on this build), document serialization shapes, and rendered output are only stable against one exact engine commit. Deterministic tooling requires a deterministic engine.
- **Zero modifications:** the engine source is never modified; the clone stays pristine (`git status --porcelain` empty after build — asserted by `scripts/fetch-engine.sh`).
- **Upgrading is a deliberate re-pin:** choose a new upstream commit, update the pinned SHA in `scripts/fetch-engine.sh` and `docs/ENGINE-PIN.md`, rebuild, and re-run the **full test suite** (including the real-CLI e2e tiers) before relying on the new build. Node-registry content and serialization shapes can change between upstream versions.
- **Support boundary:** bugs in this repository (the MCP server, builder, runner, validator) belong here; questions about engine behavior belong upstream at `GraphiteEditor/Graphite` — this wrapper changes no engine code. The SIGSEGV-after-write quirk is an upstream behavior, absorbed by the quarantine contract above.
## Integration
Register the server with your MCP client (Claude Desktop, Cursor, or any client supporting the Model Context Protocol). Give it the absolute path to the built entrypoint:
```json
{
"mcpServers": {
"graphite-editor-mcp": {
"command": "node",
"args": ["/absolute/path/to/graphite-editor-mcp/dist/src/index.js"]
}
}
}
```
To explore the server interactively:
```bash
npx @modelcontextprotocol/inspector node dist/src/index.js
```
A scripted non-interactive stdio handshake lives at `scripts/stdio-probe.mjs` (`node scripts/stdio-probe.mjs dist/src/index.js graphite_list_nodes '{"filter":"GradientMap"}'`).
## Limitations
- **No PDF / no print pipeline** — the CLI rejects PDF.
- **Fallback-font text only** — the text template uses the engine's embedded fallback font. The raw-`TextNode` resource failure is fully decoded and the authoring-shape fix is execution-verified; see `docs/font-resource-investigation.md` — embedded brand fonts would need the `.gdd` resource route (future work, not this build).
- **Sequential renders in v1** — one graphene-cli child at a time; `graphite_render_variants` runs its variants in a plain loop.
- **Debug-binary latency** — the pinned debug build spends ~1–1.5 s per spawn (startup + render); a release build (`cargo build --release -p graphene-cli`, then point `GRAPHITE_CLI_PATH` at it) cuts that substantially.
- **Inline documents are materialized and kept** under `tmp/mcp-inline/` (small JSON files; gitignored) so error messages always point at a real document on disk.
- `graphite_list_nodes` caches the registry per process lifetime.
- Strictly a wrapper: no upstream source modifications, no GUI driving.
## Development
```
npm run typecheck # tsc --noEmit
npm run build # tsc -p tsconfig.json → dist/src/*.js
npm test # vitest run — 122 tests, 5 files
```
Test layout (`tests/`):
- `runner.test.ts` / `validator.test.ts` — process-runner and artifact-validation units against **synthetic fake CLIs** in `tests/synthetic/*.mjs` (crash-after-write, clean exits, hangs, garbage writes) — the real binary is never required.
- `builder.test.ts` / fixtures — builder + template round-trips against committed proven documents in `tests/fixtures/`.
- `templates-e2e.test.ts` — real-CLI renders of all four templates, parametric pixel checks, determinism. Skipped as a whole when the binary is absent (`describe.skipIf`).
- `mcp-e2e.test.ts` — the MCP surface over the SDK's `InMemoryTransport`: a protocol tier (no binary needed: tool advertisement, `SPEC_INVALID` envelopes, structural validation) and a real-CLI tier (template/inline renders, list-nodes, compile check, variants batch).
Engine pin: `docs/ENGINE-PIN.md` records the pinned commit, the reproducible `scripts/fetch-engine.sh`, and the reference binary sha256; the clone (`engine/`) is gitignored and never modified.
---
Built by [The Native Creative](https://thenativecreative.co.za).
Licensed under the [MIT License](LICENSE) — Copyright (c) 2026 The Native Creative.TDQS
Scored across 4 tools
Each tool has a clearly distinct role: single rendering, batch variant rendering, node introspection, and structural validation. render and render_variants are related but the descriptions make the batch/parametric purpose unambiguous.
All tools follow the same snake_case graphite_ prefix and a clear verb_noun pattern: render, list_nodes, validate_doc, render_variants. The naming is uniform and predictable.
Four tools fit the narrow rendering/validation scope well and each tool earns its place. The count is within the ideal 3-15 range and does not feel padded or insufficient.
The surface covers the main workflows: render, render variants, validate, and inspect known nodes. Minor gaps exist, such as no template-listing tool or document editing capability, but the stated domain appears rendering-focused.