MCP Tool Reference
The relay embeds a Model Context Protocol server so external AI agents (Claude Code/Desktop, Cursor, Zed, ChatGPT, Notion, and any other MCP-capable client) can create and manipulate DocuShark documents — not just edit existing ones. This is the "publish target" surface: an agent can author a real document, with prose and diagrams, and a human drops into the editor when they want to touch it visually.
This page is the authoritative reference for the MCP surface. The REST/WS wire spec lives at REST API Reference.
Endpoint & transport
- HTTP endpoint:
POST /mcp. JSON-RPC 2.0 over HTTP (streamable-HTTP MCP). - Send
Accept: application/json. If you sendAccept: text/event-stream, the server replies with SSE framing; plain JSON clients should not advertise the event-stream type.
Where it listens — [mcp].expose
expose | Where /mcp lives | Use |
|---|---|---|
local (default) | a loopback-only listener on [mcp].port (default 9877) | desktop / self-host on one machine |
public | folded onto the relay's main HTTP listener (the one already serving /ws + REST, [server].port) | a relay reachable over the network, so a remote MCP client can connect |
With expose = "public" there is no second port or TLS endpoint: /mcp and its discovery doc ride the relay's real origin. [mcp].port is ignored.
Authentication
Every request carries Authorization: Bearer <token>. Two credential types are accepted, tried in this order:
- Static MCP token — a per-host bearer (generated on first run, persisted to
mcp_tokenunder the data dir, logged at startup). Authenticates as the single-tenant (default) workspace. This is the desktop / local default. Refused whenexpose = "public"— it resolves to the catch-alldefaultworkspace, so a network-reachable (potentially multi-tenant) pod accepts only the JWT below. - Relay app token (JWT) — an RS256 token minted by the OIDC issuer; validated via JWKS. The workspace is taken from the token's
wspclaim (see Token Format). This is how a multi-workspace deployment scopes an agent to one workspace.
A missing or invalid credential returns 401 with no disambiguation.
OAuth discovery (self-serve auth)
So an MCP client can authenticate without a hand-pasted token, /mcp advertises its authorization server per the MCP auth profile (2025-06-18):
- An unauthenticated
/mcprequest returns401withWWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource". GET /.well-known/oauth-protected-resource(RFC 9728) returns the resource identifier (<origin>/mcp) and the configured authorization server ([auth].issuer). The client runs the auth-code + PKCE dance there and comes back with a JWT. The discovery URLs echo the origin the request arrived on (honoringX-Forwarded-Protobehind a TLS-terminating proxy), so they're correct whether reached on loopback or a public host.
Document model
A DocuShark document carries two surfaces in one object:
- Canvas pages (
pages) — the diagram: shapes and connectors. - Prose pages (
richTextPages) — the written body, stored as HTML.
A document may also carry a reference library (references) — citations as CSL-JSON keyed by id, with an explicit display order — manipulated by the citation tools below.
The MCP tools operate on both. Prose is authored in Markdown by default (agents produce it reliably) and rendered to the HTML the editor persists; pass format: "html" to supply HTML directly.
Tools
All tools are namespaced docushark_*.
Read
| Tool | Purpose |
|---|---|
list_documents | List documents in the workspace (id, name, modifiedAt, source, and page counts). pageCount is the canvas + prose total; canvasPageCount and prosePageCount give the breakdown (a document has a diagram canvas and a separate prose body). |
get_document | Document metadata + canvas pages summary + prosePages summary + fields (the document's {{name}} values). The map of what exists. |
get_page | The shapes on one canvas page, as DSL objects in z-order; coordinates are shape centres, rounded to 2 decimals. Optional filters combine: ids, kinds, text (case-insensitive substring) and region ({minX, minY, maxX, maxY}; connectors attached to a selected shape come along). fields projects each shape to the listed keys (id is always kept), and summary: true returns the matching count by kind and bounding box instead of the shapes. Read a large page summary-first. |
get_shape | One shape on a page, by id, as a DSL object (the read-one companion to get_page). |
get_prose | All prose pages (or one, with pageId): id, name, order, HTML content, and revision (for the whole-page writers' expectedRevision). |
get_outline | A prose page's heading outline: ordered { index, level, title, id } (id is the heading's durable block id), plus the page's revision. Name headings by id in the structural tools; an index shifts when a heading is added above it. |
list_references | The document's reference library as CSL-JSON in display order, plus the active style. |
resolve_doi | Resolve a doi to a CSL-JSON reference via doi.org content negotiation, without writing — preview before add_reference. |
list_fields | The document's fields (reusable {{name}} values) in display order, each { name, value }. |
get_skills | Agent onboarding: with no args, the content contract (rules for valid prose + shapes) plus a recipe catalogue; with { skill }, that recipe's full steps. Call it first if unsure how a tool expects its input — authoring valid content avoids the relay having to heal it. |
list_icons | Discover icon IDs to put on shapes: { id, name, category } entries plus the match total and available categories. Filter with query (substring over id + name) and/or category; cap with limit (default 50, max 200). Apply an id via add_shape/update_shape (iconId). |
list_files | The document's embedded files/blobs: { id, name, mimeType, size }. Companion to the blob://<hash> image refs get_prose returns. |
get_file | One embedded file by id/hash — its metadata (and content where applicable). |
get_table | A prose table's structure — rows, cells, and any colspan/rowspan/colwidth — for span-aware edits via edit_table. Also returns the page's revision. |
get_storage | The workspace's storage usage: blob + document bytes against the quota. |
Author
| Tool | Purpose |
|---|---|
create_document | Create a new document (one blank canvas page + one blank prose page). Returns { id, name }. The starting point for authoring from scratch. |
rename_document | Rename a document (set its title). Updates the title live for anyone who has the document open. Returns { id, name }. |
Prose (write)
| Tool | Purpose |
|---|---|
add_prose_page | Append a prose page. Markdown by default. |
set_prose | Write a prose page. Replaces the whole body by default; pass anchor (the current text of a block) or id (a durable blk-… block id) to replace only that block — a targeted edit. Markdown by default; format: "html" supplies styled HTML directly (marks, cell spans, task lists round-trip). A whole-page write returns the page's new revision and accepts expectedRevision (refused with ERR_STALE_WRITE if the page changed after that read; not accepted with anchor/id, which already check their block). |
insert_block | Insert a prose block at a position relative to a durable block id. |
delete_block | Delete a prose block by its durable id. |
move_block | Move a prose block to a new position by id. |
rename_prose_page | Rename a prose page. |
Structure (write)
| Tool | Purpose |
|---|---|
insert_section | Insert a heading (+ optional body) at start/end, or after a heading named by afterId (preferred) or afterIndex. Returns the new section, the outline and the page's new revision; accepts expectedRevision. |
restructure_outline | promote/demote a heading's level, or move a section to the position another heading holds now. Name headings by id/toId (preferred) or index/toIndex; giving both requires them to agree (ERR_ID_INDEX_MISMATCH). Returns the affected section, the outline and the page's new revision; accepts expectedRevision. |
edit_table | Structural prose-table edits — add/remove rows & columns, merge/split cells, set headers or column widths (span-aware). Pair with get_table. Returns the page's new revision; accepts expectedRevision. |
Diagram (write)
| Tool | Purpose |
|---|---|
add_canvas_page | Append a blank canvas page; returns { id }. Target it with the returned id in add_shape(s)/generate_diagram to build a second diagram in the same document. |
rename_canvas_page | Rename a canvas page. |
add_shape | Add one shape (rectangle, ellipse, text, connector). Rectangles/ellipses may carry an icon: iconId (from list_icons), iconDisplayMode (inside/badge/icon-only), iconSize. |
add_shapes | Add many shapes in one all-or-nothing call. |
connect | Connect two existing shapes with a connector. |
update_shape | Patch an existing shape (x, y, w, h, text, style, and icon fields iconId/iconDisplayMode/iconSize — an empty iconId clears it). |
generate_diagram | Build a whole diagram from a nodes + edges graph; the relay auto-positions (layered with crossing minimization, or grid) and wires connectors to typed anchors with orthogonal obstacle-avoiding routing (routing: "straight" opts out). Writes the live Y.Doc on a resident doc (a connected editor sees the shapes immediately) — like the other shape tools. |
add_file | Embed a file (blob) in the document — a PDF, image, spreadsheet, or dataset — and sync the document's blob references. Companion to list_files/get_file. |
Icons on shapes. Call list_icons (filter by query/category) to find an id — e.g. cloud-aws/cloud-azure/cloud-gcp service icons for architecture diagrams, or devops/databases/languages/frameworks — then set iconId on a rectangle/ellipse via add_shape/update_shape. iconDisplayMode: "icon-only" makes a pure icon node (no fill/stroke, icon fills the bounds); inside (default) or badge keep the box. generate_diagram nodes don't take icons directly — build those with add_shape(s).
Manage (write)
| Tool | Purpose |
|---|---|
delete_shape | Delete a shape by id. Cascade-removes any connectors attached to it (start or end), so no dangling connectors are left; returns the ids actually deleted. |
delete_prose_page | Delete a prose page by id. Refuses to delete the last remaining prose page. In a connected editor the page's tab may persist until reload (the prose page list isn't yet live-synced); its content is cleared immediately. |
delete_canvas_page | Delete a canvas page by id. Refuses to delete the last remaining canvas page. Repoints the active page if the deleted one was active; a connected app reloads. |
delete_document | Permanently delete a whole document and all its pages, prose, and blobs — cannot be undone on the server. Anyone currently viewing it has it moved to their local Trash. Returns { deleted: id }. |
reorder_shapes | Set a page's z-order. order must be a permutation of the page's current shape ids (later ids render on top). |
reorder_prose_pages | Set the order of a document's prose pages. order must be a permutation of the current prose page ids. Tab order may update on reload (page list not yet live-synced). |
reorder_canvas_page | Set the order of a document's canvas pages. order must be a permutation of the current canvas page ids. Tab order updates on reload (page list not live-synced). |
Citations (write)
| Tool | Purpose |
|---|---|
add_reference | Add reference(s) to the document's library. Supply either doi (resolved via doi.org to CSL-JSON) or items (raw CSL-JSON object(s)). Dedups by DOI then id; returns the ids added + how many were skipped. |
update_reference | Repair an existing entry. id + item (CSL-JSON); fields merge by default (an explicit null clears one), replace: true swaps the whole item. The id is the library key and cannot change — renaming it would orphan every citation pointing at it. Fails on an unknown id. |
delete_reference | Remove an entry from the library and the bibliography. Refuses while the reference is still cited inline, naming the page/block sites and the count; force: true deletes anyway and leaves those citations unresolved. |
These tools manage the library only; they don't write into the prose. To cite inline, write <span data-citation data-ref-id="<id>" data-label="(Author, Year)">(Author, Year)</span> with set_prose (format: "html"). To add the reference list, place an empty <div data-bibliography></div> where it belongs, in Markdown or HTML. The library is stored as the document's top-level references field and round-trips durably; a connected editor sees new references on reload, as references aren't live-synced yet. Formatting (CSL → APA/MLA/Chicago/Vancouver) is done in the editor, not the relay — the MCP surface only reads and writes CSL-JSON — so the editor renders a placed bibliography and caches the result in the node. Until an editor has opened the document once, an export or get_prose shows the bibliography node without a list.
Fields (write)
| Tool | Purpose |
|---|---|
set_fields | Set/update document fields — reusable named values (e.g. Company → Acme Inc., Version → 2.0). Upsert by name: a new name is created, an existing name's value is replaced. Returns the names written + which were newly added. |
A field is a name → value pair that propagates everywhere it's referenced. To reference a field in prose, write {{name}} in Markdown via set_prose/add_prose_page. The value is resolved at write time, so the stored HTML carries it (<span data-field data-name="name" data-label="value">value</span>) and an export, get_prose, or a non-collaborative open shows the value without the document ever being opened in an editor; a connected editor still re-derives it live when the value changes. A name with no matching field stays an unresolved placeholder (<span data-field data-name="name">) for a live editor to fill. {{name}} also works inside a link or image destination, where the value replaces the token in the URL itself. ({{name}} inside inline code or a code block stays literal.)
Fields are stored as the document's top-level fields object — { fields: { "<name>": { name, value } }, order: [...] } — and round-trip durably. On a connected (resident) document, set_fields writes the live CRDT library and broadcasts, so collaborators see new values immediately, per item (a concurrent editor's field isn't clobbered).
(Renames: rename_document, rename_prose_page, rename_canvas_page.)
Write feedback — the fixes array
Write tools succeed even when they have to adjust your input, and they say so rather than failing silently. When an adjustment happened, the result carries a fixes array; a clean write omits it entirely. Each entry has an action and a human reason:
dropped_unknown— an unrecognized key (e.g.fillColorforstyle.fill) was dropped. Thefieldnames it.dropped_invalid— a recognized field carried an unaccepted value (e.g.routingMode:"zigzag"), dropped to the field default.clamped— a value was coerced into range (e.g. headinglevel9 → 6,labelPosition1.5 → 1.0).- prose heals (
unwrapped/rebuilt_table/stripped_children) from the prose validator (set_prose / add_prose_page). - prose losses: HTML the editor has no equivalent for, reported by
set_prose,insert_blockandadd_prose_page.unwrapped_element(an element such as<kbd>was replaced by its text, losing its formatting or meaning),removed_element(an element that keeps no content, such as an empty unknown element,<script>or<style>, was removed) andstripped_atom_text(text written inside a field, citation, file or math atom, which keeps its value in an attribute).nodeTypenames the element or atom,countsays how many times it happened, andpathis the top-level block of the first one.
add_shapes tags each fix with the offending shape index. Read the array, correct the source, and re-send — nothing is silently lost. (Prefer authoring valid input up front; see get_skills.)
Concurrency
Live docs (a client is connected/editing). When a document is resident on the relay, writes apply to the authoritative Y.Doc and broadcast a CRDT delta, so connected editors see the change immediately (they merge it — no reload):
- Shape tools (
add_shape/add_shapes/connect/update_shape/delete_shape/reorder_shapes/generate_diagram) write the live shape map when the doc is resident and the target page is the active page. (Canvas page-list tools —add_canvas_page/rename_canvas_page— are JSON-only, like the prose page list; a new page's tab may lag until reload.) - Prose tools (
set_prose/add_prose_page/insert_section/restructure_outline/insert_block/delete_block/move_block) apply to the page's liveprose:<pageId>fragment when the doc is resident — so an agent's prose appears in a connected editor live, and an MCP read reflects an editor's un-snapshotted prose. (A newadd_prose_pagepage's tab may lag until the prose page list syncs; its content lands immediately.) - Whole-page prose writes are surgical (JP-441). Even a full
set_prosebody replace diffs against the live fragment and rewrites only the block window that actually changed — so an unchanged block's identity, a collaborator's caret and scroll position, and concurrent edits outside the changed window all survive. It is not a blunt whole-fragment swap. - Revisions and stale writes. Every prose read (
get_prose,get_outline,get_table) returns the page'srevision, a hash of its content. Pass it asexpectedRevisionto a whole-page writer (set_prosewithoutanchor/id,insert_section,restructure_outline,edit_table) and the write lands only if the page is unchanged since that read: the check is atomic with the write, live or cold. Otherwise the write is refused withERR_STALE_WRITEand nothing is written; re-read and apply the change to the current content. Even withoutexpectedRevision,insert_section,restructure_outlineandedit_tablenever overwrite a concurrent change: if the page changes between their own read and write, they re-read and recompute. Name headings byidrather thanindexin the outline tools, since an index shifts when a heading is added above it. - Anchored / block-targeted edits (
set_prosewithanchororid, plusinsert_block/delete_block/move_block) touch only the addressed block.anchordoubles as a block-level compare-and-swap — it must match exactly one block, so a stale anchor is refused (ERR_ANCHOR_*) rather than clobbering drifted content. Durable block ids (blk-…, surfaced byget_outline/get_proseand auto-filled on write) are the drift-proof alternative; a stale id fails hard (ERR_ID_NOT_FOUND/ERR_ID_AMBIGUOUS/ERR_ID_ANCHOR_MISMATCH).
Cold docs (no client connected). Writes persist through an optimistic-concurrency check on the document's serverVersion: read the current version, apply, save only if it still matches, retrying on conflict — so a concurrent editor's change is never silently clobbered. The relay's snapshot flatten later projects live edits back into this JSON, so cold reads stay current.
Limits & current constraints
- Local documents are read-only via MCP. Renderer-owned (local) documents are mirrored read-only; writes target relay documents only, and a write to a local id is refused with a clear message.
- Prose is HTML. Markdown in is rendered (GFM tables / strikethrough / task-lists on). HTML is parsed with the editor's own rules: its synonyms (
<b>,<i>,<del>,<strike>), CSS styles on any element (as in a Google Docs paste) and GFM checkbox task lists are accepted; anything unmodelled degrades to its text and is reported infixes. A shared fixture (relay/tests/prose-fixtures/mark-synonyms.json) is run through both the editor's schema and the relay's parser, so the two cannot drift. - Prose write limits. A single prose write's content is capped at ~1 MiB (
set_prose/add_prose_page/insert_section; over it →ERR_PROSE_TOO_LARGE, advertised asmaxLength). Nesting deeper than 64 levels is truncated (real prose nests <~10) — a safety bound so pathological input can't exhaust the server. - Storage quota (shared meter). Document writes count against the workspace storage meter (blob bytes + document JSON bytes). A growing write that would exceed the workspace
quota_bytesis refused withERR_STORAGE_QUOTA; a single document over the per-document ceiling (max_doc_bytes) is refused withERR_DOC_TOO_LARGE. Shrinking/equal writes and deletes always land. See Token Format. - Outlines are flat. A section is a heading plus the content up to the next heading; nesting is conveyed by
level, not containment.movemoves a single section, not its descendants. generate_diagramlayout is relay-side and self-contained. Layered mode runs a full Sugiyama pipeline (cycle handling, crossing minimization, deterministic coordinates); connectors attach to typed anchors and are routed orthogonally around intervening shapes with explicitwaypoints(routing: "straight"opts out), so the document looks right before any editor opens it. The same input always reproduces the same geometry. The editor re-routes connectors with the same algorithm when shapes move. Connectors route around nodes, not around each other — heavily parallel edges can still overlap. Node caps: 500 nodes / 1000 edges per call.- Tool results. Every result is returned twice: as
structuredContentand as a text block holding the same JSON, compact. Some clients cap how much of a result they show the model inline, and a fullget_pageof a diagram with about 100 shapes is near such limits: read large pages withsummary: truefirst, then filter and project. - Rate limits (per workspace). Every tool that can change a document (any tool whose
tools/listannotations do not sayreadOnlyHint: true) draws from the shared write bucket ([tenancy.limits] writes_per_sec/writes_burst, shared with WS sync); read-only tools draw from a separate bucket (reads_per_sec/reads_burst,0= unlimited). Over the bucket → HTTP429withERR_RATE_LIMIT. Tunable viaRELAY_*env on Cloud pods. - Tool annotations and server instructions. Every tool in
tools/listcarries MCP annotations:readOnlyHintandopenWorldHinton all tools, plusdestructiveHintandidempotentHinton tools that write. A client can use them to confirm destructive calls and to avoid blindly retrying non-idempotent ones such asgenerate_diagram.initializereturns shortinstructions: callget_skillsbefore the first write, shape coordinates are centres,labelColor: "auto"keeps labels readable, caller-chosen shape ids make retries safe, and documents that may be open should be edited one block at a time. - No open-in-editor link yet.
create_documentreturns the documentid; a deep link back into the editor is not yet wired.
Example: author a document from scratch
// 1. create
{"method":"tools/call","params":{"name":"docushark_create_document",
"arguments":{"name":"Architecture RFC"}}}
// → { "id": "doc-…" }
// 2. discover the page ids
{"method":"tools/call","params":{"name":"docushark_get_document",
"arguments":{"docId":"doc-…"}}}
// → pages:[{id:"page-…"}], prosePages:[{id:"page-…"}]
// 3. write prose (Markdown)
{"method":"tools/call","params":{"name":"docushark_set_prose",
"arguments":{"docId":"doc-…","pageId":"<prose page>",
"content":"# Overview\n\n## Goals\n\n## Approach"}}}
// 4. draw a diagram
{"method":"tools/call","params":{"name":"docushark_generate_diagram",
"arguments":{"docId":"doc-…","pageId":"<canvas page>",
"nodes":[{"id":"client","label":"Client"},{"id":"relay","label":"Relay"}],
"edges":[{"from":"client","to":"relay","label":"WebSocket"}]}}}A runnable end-to-end smoke test of every tool lives in scripts/mcp-smoke.sh.
Per-provider verification
The bar for this surface is "smooth on every MCP provider," not "works on one." Each provider should be able to (a) create a document, (b) generate a structured diagram, and (c) restructure an outline. Status:
| Provider | Status |
|---|---|
| Claude Code | ✅ verified (create → prose → outline → diagram round-trip) |
| Claude Desktop | ☐ to verify |
| Cursor | ☐ to verify |
| Zed | ☐ to verify |
| ChatGPT (custom GPT) | ☐ to verify |
| Notion custom agents | ☐ to verify |
| Atlassian Rovo | ☐ to verify |
To add a provider: point it at /mcp on the relay (loopback http://127.0.0.1:9877/mcp with expose = "local", or the relay's origin https://<host>/mcp with expose = "public") with the bearer token, run the three acceptance steps, and tick the box.
