Skip to content

diagrams

Create, update and read diagrams (flowchart/ERD/sequence) in public.diagrams, optionally linked to a work.epics entity via entity_id — a diagram can also stand alone, at a visibility cost.

Endpointhttps://engineering.mcp.devfellowship.com/mcp
Tools8
Backing datapublic.diagrams, public.diagram_versions, read of work.epics for entity resolution.
ToolDescription
create_diagramThe way to make a diagram anywhere in DFL — use it instead of writing a Mermaid code block into a plan, an ADR or a document. Creates a row in public.diagrams and returns its uuid. epic_id is optional: pass it to file the diagram under a work.epics id (entity_id = epic_id), or omit it for a stand-alone diagram — the shape a diagram takes when it belongs to a plan. Accepts either mermaid_text (auto-parsed and auto-laid-out) or explicit nodes/edges in the native @xyflow/react shape. Enforces the node-label cap.
update_diagramUpdate an existing diagram by id — any subset of name, description, type, nodes, edges, or a wholesale mermaid_text replacement. The uuid does not change, so every plan and document that references it follows along. Owner-scoped by RLS. Enforces the same node-label cap.
get_diagramGet a specific diagram by ID, including nodes/edges — what a {{dfl-diagram:<uuid>}} token actually points at.
list_diagramsFind an existing diagram and its uuid. Filter by epic_id to get all diagrams for a work.epics entity; omit it to list across every epic and the stand-alone, epic-less diagrams. Run it before create_diagram to avoid a duplicate.
export_diagramExport a diagram by id to a shareable artifact — svg (default), mermaid or plantuml. For surfaces that cannot resolve a reference token; see Exporting a diagram.
snapshot_diagram_versionSave the diagram’s CURRENT nodes/edges as a new row in public.diagram_versions. See Diagram revisions.
list_diagram_versionsList a diagram’s revision history (newest first), with node/edge counts instead of the full payload. See Diagram revisions.
get_diagram_versionGet one specific revision’s full nodes/edges, by id or by diagram_id + version_number. See Diagram revisions.

The diagram is stored once and embedded by reference everywhere else. The token is:

{{dfl-diagram:<uuid>}} ← short form
{{dfl-entity:diagram:<uuid>}} ← long form, identical meaning

The consuming app resolves the token when it renders, which is the whole point: editing the diagram reaches every plan and document that points at it, and creates no new plan version.

In a plan — do not hand-write the token. Use the plans MCP (https://plans.mcp.devfellowship.com/mcp):

  1. create_diagram here → take the id from the response.
  2. attach_entity there, with the plan slug, type: "diagram" and that uuid as locator.

To pin the embed to a fixed state (inside an ADR or a decision block), pass rev as well — a public.diagram_versions id from list_diagram_versions. mode: "pinned" without a rev cannot be honoured and renders live content under a badge.

In a document — put the diagram sentinel ({{dfl-diagram:00000000-0000-0000-0000-000000000000}}) in the template or the literal body and pass diagram_entity_id to write_document; every diagram of that entity is injected as a real token. write_handoff_document does the same automatically for the handoff category.

Anywhere else (a GitHub PR body, a README, a slide) nothing resolves DFL tokens, so that is what export_diagram is for.

export_diagram is the way to get a diagram out of the system — as a picture for a client deck, or as text for a PR body. It renders from the diagram’s persisted position/measured values, so the output matches the layout a user sees in the app rather than re-running layout.

Supported format values:

  • svg (default) — standalone vector image, image/svg+xml
  • mermaid — Markdown-fenced Mermaid source, text/vnd.mermaid
  • plantuml — @startuml … @enduml, text/vnd.plantuml

Options: theme (dark, the default, matches the app canvas — or light for print/docs) and scale (SVG only, default 2).

The response carries the artifact plus filename, mime_type, and node_count/edge_count so you can sanity-check that nothing was dropped:

{
"id": "39b0e55f-…",
"name": "RMT CRM — Domain Data Model",
"diagram_type": "erd",
"format": "svg",
"mime_type": "image/svg+xml",
"filename": "rmt-crm-domain-data-model.svg",
"node_count": 25,
"edge_count": 28,
"content": "<svg xmlns=\"http://www.w3.org/2000/svg\" …"
}

Rasterising needs a DOM or a native rasteriser, and this package runs headless in Node with neither. That constraint happens to point at the better artifact anyway: an export tool cannot know what resolution its caller needs, and a PNG that came out too small to read is the exact failure this tool exists to avoid. SVG is vector, so there is no resolution to get wrong, and it is text, so it travels through MCP without base64 bloat.

scale only feeds the root width/height attributes, so a naive svg → png conversion is already hi-dpi. The viewBox never changes — scale cannot crop or reflow the diagram.

Terminal window
# rasterise at whatever DPI you actually need
rsvg-convert -d 192 -p 192 diagram.svg -o diagram.png

export_diagram dispatches on node.type rather than on diagram type, so erd, flowchart and sequence all render through one path, and an unrecognised node type degrades to a labelled box instead of disappearing.

Both write tools (create_diagram and update_diagram) reject — never truncate — any node whose data.label (or data.name, for ERD entities and sequence lifelines) is longer than 60 characters. The rejection names every offending node id and its length, writes nothing to the database, and tells you where the text belongs instead:

{
"error": "1 node label(s) exceed the 60-character limit: q6 (data.label, 214 chars: \"OPEN QUESTION 6 - customer ID rules are u…\"). A node label is a box on a canvas, not a sentence — keep it a short noun phrase (<= 60 chars) and move the prose to that node's \"data.description\", which has no length limit and is surfaced as node detail. Nothing was written and nothing was truncated: resend the diagram with shortened labels."
}

Where prose goes: data.description. It has no length limit and is rendered as node detail rather than as the box label, so a requirement paragraph, an open question or a design rationale is preserved verbatim and stays attached to its node:

{
"id": "q6",
"type": "data",
"position": { "x": 0, "y": 0 },
"data": {
"label": "Open question 6: customer ID",
"description": "Customer ID rules are undefined. Sequential per tenant or global, which email when the contact has several, what padding, and what happens when the email local part is shorter than five characters."
}
}

The cap applies to the mermaid_text path too, because Mermaid node text becomes data.label on import (Q6[some very long sentence]) — that is precisely how the unreadable diagrams of 2026-08 were produced. Shorten the label inside the Mermaid source and add the prose via data.description on a follow-up update_diagram call with explicit nodes.

Why 60. The auto-layout bounds a node box at 280px wide; at the canvas font size that fits roughly 36 characters per line, so 60 characters wraps to at most two lines and stays inside the box the layout engine actually reserved for it. Longer labels render a box several times wider than the space dagre allocated, and the stored positions collide on open. estimateNodeSize() now derives width (bounded) and height (wrapped-line count) from the real label instead of returning a constant 180×60, so generated positions are honest even for pre-existing rows being re-laid-out.

Rejection is deliberate over truncation: truncating a label silently destroys a client requirement that the author meant to record.

Pass mermaid_text instead of type/nodes/edges to create a diagram directly from Mermaid source (flowchart, erDiagram, or sequenceDiagram — optionally fenced in ```mermaid blocks). The type is auto-detected and nodes are auto-laid-out (via @dagrejs/dagre, ported from dfl-diagrams/src/lib/mermaid/); any explicit nodes/edges passed alongside mermaid_text are ignored. PlantUML import is not supported yet — only Mermaid.

{
"epic_id": "...",
"name": "Checkout flow",
"mermaid_text": "flowchart TD\nA[Start] --> B{Payment OK?}\nB -->|Yes| C[Confirm]\nB -->|No| D[Retry]"
}

Malformed mermaid_text returns a structured {"error": "Failed to parse mermaid_text: ..."} response (not a thrown error) and never touches the database.

update_diagram edits an existing row by id. Every field except id is optional and omitted fields are left untouched; nodes/edges are a full replacement, not a merge.

  • id — string, required. public.diagrams.id (UUID).
  • name — string, optional. New diagram name.
  • description — string, optional. New diagram-level description (the public.diagrams.description column — distinct from a node’s data.description).
  • type — "flowchart" | "erd" | "sequence", optional. Ignored when mermaid_text is given.
  • nodes — object[], optional. Replacement nodes, native @xyflow/react shape. Ignored when mermaid_text is given.
  • edges — object[], optional. Replacement edges, native @xyflow/react shape. Ignored when mermaid_text is given.
  • mermaid_text — string, optional. Replaces type + nodes + edges wholesale from Mermaid source; type auto-detected and nodes auto-laid-out.
  • auto_layout — boolean, optional (default false). Re-run the dagre layout over the supplied nodes, overwriting their positions. Always applied on the mermaid_text path.
{
"id": "d8f56c71-…",
"name": "Deal Pipeline",
"auto_layout": true,
"nodes": [
{ "id": "q6", "type": "data", "position": { "x": 0, "y": 0 },
"data": { "label": "Open question 6: customer ID", "description": "…full paragraph…" } }
],
"edges": []
}

Responses: {"success": true, "diagram": {…}} on success; {"error": "Nothing to update: provide at least one of name, description, type, nodes, edges, mermaid_text"} when no mutable field is passed; {"error": "Diagram not found or not owned by you: <id>"} when the row does not exist or the owner-scoped RLS policy hides it from the caller (the two are indistinguishable by design); and the node-label rejection above when a label is over the cap.

There is still no delete tool — a deliberate absence across DFL MCP packages, not a gap to fill.

Before snapshot_diagram_version existed, update_diagram did a full replacement of nodes/edges with no history — editing a diagram silently destroyed the previous state, with no recovery path. public.diagram_versions closes that gap: each row is a point-in-time copy of a diagram’s nodes/edges, numbered per-diagram from 1.

  • snapshot_diagram_version — diagram_id (required) + commit_message (optional). Reads the diagram’s CURRENT nodes/edges and inserts a new diagram_versions row with version_number = COALESCE(MAX(version_number), 0) + 1 for that diagram. Retries a handful of times if two callers race to compute the same next version_number (the UNIQUE (diagram_id, version_number) constraint fires and the retry recomputes MAX rather than failing opaquely). Returns the created row’s id, version_number, commit_message, created_at, and node_count/edge_count — not the full payload. Owner-scoped: the same {"error": "Diagram not found or not owned by you: <id>"} shape as update_diagram when the caller doesn’t own the parent diagram.
  • list_diagram_versions — diagram_id (required) + limit/offset (optional, default 50/max 100). Returns version rows ordered version_number DESC (newest first) with node_count/edge_count instead of the full nodes/edges, so the response stays small even for a diagram with a long revision history.
  • get_diagram_version — identify the revision by its own id, or by diagram_id + version_number. Returns the full stored nodes/edges for that revision — this is the tool that makes a prior state actually recoverable, as opposed to list_diagram_versions’s counts-only rows.

update_diagram takes two new optional arguments:

  • snapshot — boolean, default true. When true, the diagram’s PRE-update nodes/edges are snapshotted into diagram_versions (same insert path as snapshot_diagram_version) before the update is applied. If that snapshot fails for any reason, the update is aborted — nothing is written to public.diagrams either. An update that cannot be rolled back is not an update, it is an overwrite; making the snapshot the default is what makes the revision mechanism real rather than optional.
  • commit_message — string, optional. Attached to the pre-update snapshot (ignored when snapshot: false). Defaults to "auto: pre-update snapshot (mcp)" when omitted — deliberately distinct from the dfl-diagrams editor’s own "auto: baseline" / "auto: checkpoint" conventions, so the history can tell an MCP-tool-driven revision apart from one written by a human in the editor.

Callers that genuinely don’t want a revision recorded (e.g. a high-frequency autosave path) pass snapshot: false to skip it entirely — no snapshot is inserted and the update behaves exactly as before this feature existed.

version_number is computed via the existing public.get_next_version_number(p_diagram_id) RPC — the same one dfl-diagrams’ own editor checkpoint path uses (src/hooks/useDiagramVersions.ts) — rather than a second, independent “what’s the next version” query. Both snapshot_diagram_version and update_diagram’s auto-snapshot retry up to 3 times on the diagram_versions_diagram_id_version_number_key unique-violation race, matching the editor’s own retry semantics for the same RPC.

// snapshot the current state before applying a change
{ "id": "d8f56c71-…", "name": "Deal Pipeline v2", "commit_message": "Added retry step after payment decline" }
// skip the revision entirely
{ "id": "d8f56c71-…", "name": "Deal Pipeline v2", "snapshot": false }

To recover a prior state, read it back with get_diagram_version and re-apply it via update_diagram (passing its nodes/edges — update_diagram’s own default snapshot: true means even that recovery write is itself recorded as a new revision).

epic_id is optional — and what omitting it costs

Section titled “epic_id is optional — and what omitting it costs”

create_diagram used to require epic_id. It no longer does, and the requirement was never a database rule: public.diagrams has no epic_id column and never had one. It has a generic, nullable, foreign-key-less entity_id (plus a denormalised entity_name). The requirement lived only in this tool’s Zod schema, so a diagram whose subject was a plan — which has no epic — had no correct home and got parked on whatever epic was nearest. This mirrors create_spec_run, which dropped the same requirement for the same reason: the epic is a pipeline hint, not the identity of the artifact.

  • Pass epic_id to file the diagram under a work.epics entity. A given id is still validated, so a non-epic id is still rejected.
  • Omit it for a stand-alone diagram. Reference it from a plan with the {{dfl-diagram:<uuid>}} token — that token is the binding, and it resolves with no epic involved.
  • Never guess a UUID to fill the field. A wrong-but-valid one files the diagram under someone else’s epic.

The epic id is a work.epics.id — not a work.projects.id

Section titled “The epic id is a work.epics.id — not a work.projects.id”

When you do pass it, create_diagram’s epic_id argument must be the id of a row in work.epics, resolved server-side via .schema('work').from('epics').select('id, name').eq('id', epic_id). It is not the project id, even though list_diagrams/get_diagram return the diagram’s entity_id alongside an entity_name that reads like a project name (e.g. "devfellowship") — that is the epic’s project name, not the epic id itself. Passing a work.projects.id here returns {"error": "Epic not found: <id>"}.

To find a real epic_id to test with, query the work domain instead (https://work.mcp.devfellowship.com/mcp, tool list_epics or get_epic) — dfl-mcp-engineering has no epic-listing tool of its own.

Connecting manually (raw HTTP, no MCP client)

Section titled “Connecting manually (raw HTTP, no MCP client)”

Useful for one-off testing (curl) without wiring up a full MCP client. The protocol is Streamable HTTP: every call after initialize must repeat the Mcp-Session-Id header returned on the first response.

Terminal window
TOKEN="<seu Bearer token, de ~/.dfl-mcp/credentials.json>"
# 1. initialize — captura o Mcp-Session-Id do header de resposta
curl -sS -D headers.txt -X POST "https://engineering.mcp.devfellowship.com/mcp" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" \
--data-binary '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}}'
SESSION=$(grep -i "mcp-session-id" headers.txt | cut -d' ' -f2 | tr -d '\r')
# 2. tools/list — confirma quais tools estao deployadas nesse momento
curl -sS -X POST "https://engineering.mcp.devfellowship.com/mcp" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SESSION" \
--data-binary '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 3. tools/call — chamada real da tool
curl -sS -X POST "https://engineering.mcp.devfellowship.com/mcp" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer $TOKEN" -H "Mcp-Session-Id: $SESSION" \
--data-binary '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_diagrams","arguments":{"limit":5}}}'

Every response is an SSE-framed line (event: message\ndata: {...}) even though the request was plain JSON — parse the data: line to get the JSON-RPC payload.

create_diagram must set created_by: userId (the caller’s own auth.uid(), extracted server-side from the validated JWT) on every insert — omitting it makes every call fail with "Database error: new row violates row-level security policy for table \"diagrams\"" (confirmed against production on 2026-07-14/15, see PR #223).

public.diagrams has two policies, and the second one is the one that decides who can read what (measured against production 2026-08-13):

PolicyCommandPredicate
Owners can manage their diagramsFOR ALLauth.uid() = created_by
Authenticated can view entity-bound diagramsFOR SELECTentity_id IS NOT NULL

Two consequences worth stating plainly, because they are not obvious from the column names:

  1. entity_id is the sharing flag. A diagram with any non-null entity_id is readable by every authenticated user — there is no epic-membership check. A diagram with a NULL entity_id is readable by its owner alone. That is why omitting epic_id on create_diagram costs visibility.
  2. The old note here is superseded. This page previously said public.diagrams had “a single owner-scoped RLS policy”, and that list_diagrams/get_diagram “only ever return diagrams owned by the calling user, never teammates’ diagrams under the same epic … flagged with Tainan, not yet resolved”. The entity-bound SELECT policy resolved that: a teammate’s epic-bound diagram is returned. Verified by reading four diagrams owned by another user as the smoke identity under enforced RLS.