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.
| Endpoint | https://engineering.mcp.devfellowship.com/mcp |
| Tools | 8 |
| Backing data | public.diagrams, public.diagram_versions, read of work.epics for entity resolution. |
Diagrams
Section titled “Diagrams”| Tool | Description |
|---|---|
create_diagram | The 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_diagram | Update 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_diagram | Get a specific diagram by ID, including nodes/edges — what a {{dfl-diagram:<uuid>}} token actually points at. |
list_diagrams | Find 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_diagram | Export 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_version | Save the diagram’s CURRENT nodes/edges as a new row in public.diagram_versions. See Diagram revisions. |
list_diagram_versions | List a diagram’s revision history (newest first), with node/edge counts instead of the full payload. See Diagram revisions. |
get_diagram_version | Get one specific revision’s full nodes/edges, by id or by diagram_id + version_number. See Diagram revisions. |
Putting a diagram in a plan or a document
Section titled “Putting a diagram in a plan or a document”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 meaningThe 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):
create_diagramhere → take theidfrom the response.attach_entitythere, with the planslug,type: "diagram"and that uuid aslocator.
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.
Exporting a diagram
Section titled “Exporting a diagram”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\" …"}Why the image format is SVG, not PNG
Section titled “Why the image format is SVG, not PNG”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.
# rasterise at whatever DPI you actually needrsvg-convert -d 192 -p 192 diagram.svg -o diagram.pngexport_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.
Node labels are capped at 60 characters
Section titled “Node labels are capped at 60 characters”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.
Importing from Mermaid text
Section titled “Importing from Mermaid text”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.
Updating a diagram
Section titled “Updating a diagram”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 (thepublic.diagrams.descriptioncolumn — distinct from a node’sdata.description).type—"flowchart" | "erd" | "sequence", optional. Ignored whenmermaid_textis given.nodes—object[], optional. Replacement nodes, native@xyflow/reactshape. Ignored whenmermaid_textis given.edges—object[], optional. Replacement edges, native@xyflow/reactshape. Ignored whenmermaid_textis given.mermaid_text—string, optional. Replacestype+nodes+edgeswholesale from Mermaid source; type auto-detected and nodes auto-laid-out.auto_layout—boolean, optional (defaultfalse). Re-run thedagrelayout over the suppliednodes, overwriting their positions. Always applied on themermaid_textpath.
{ "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.
Diagram revisions
Section titled “Diagram revisions”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 CURRENTnodes/edgesand inserts a newdiagram_versionsrow withversion_number = COALESCE(MAX(version_number), 0) + 1for that diagram. Retries a handful of times if two callers race to compute the same nextversion_number(theUNIQUE (diagram_id, version_number)constraint fires and the retry recomputesMAXrather than failing opaquely). Returns the created row’sid,version_number,commit_message,created_at, andnode_count/edge_count— not the full payload. Owner-scoped: the same{"error": "Diagram not found or not owned by you: <id>"}shape asupdate_diagramwhen the caller doesn’t own the parent diagram.list_diagram_versions—diagram_id(required) +limit/offset(optional, default 50/max 100). Returns version rows orderedversion_number DESC(newest first) withnode_count/edge_countinstead of the fullnodes/edges, so the response stays small even for a diagram with a long revision history.get_diagram_version— identify the revision by its ownid, or bydiagram_id+version_number. Returns the full storednodes/edgesfor that revision — this is the tool that makes a prior state actually recoverable, as opposed tolist_diagram_versions’s counts-only rows.
update_diagram snapshots by default
Section titled “update_diagram snapshots by default”update_diagram takes two new optional arguments:
snapshot—boolean, defaulttrue. When true, the diagram’s PRE-updatenodes/edgesare snapshotted intodiagram_versions(same insert path assnapshot_diagram_version) before the update is applied. If that snapshot fails for any reason, the update is aborted — nothing is written topublic.diagramseither. 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 whensnapshot: false). Defaults to"auto: pre-update snapshot (mcp)"when omitted — deliberately distinct from thedfl-diagramseditor’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_idto file the diagram under awork.epicsentity. 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.
TOKEN="<seu Bearer token, de ~/.dfl-mcp/credentials.json>"
# 1. initialize — captura o Mcp-Session-Id do header de respostacurl -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 momentocurl -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 toolcurl -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.
RLS: created_by must be set on insert
Section titled “RLS: created_by must be set on insert”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):
| Policy | Command | Predicate |
|---|---|---|
Owners can manage their diagrams | FOR ALL | auth.uid() = created_by |
Authenticated can view entity-bound diagrams | FOR SELECT | entity_id IS NOT NULL |
Two consequences worth stating plainly, because they are not obvious from the column names:
entity_idis the sharing flag. A diagram with any non-nullentity_idis readable by every authenticated user — there is no epic-membership check. A diagram with a NULLentity_idis readable by its owner alone. That is why omittingepic_idoncreate_diagramcosts visibility.- The old note here is superseded. This page previously said
public.diagramshad “a single owner-scoped RLS policy”, and thatlist_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 thesmokeidentity under enforced RLS.