diomedes-mcp
v1.1.0
Published
MCP server for the Diomedes wiki — read, search, and write pages over the Diomedes REST API.
Maintainers
Readme
Diomedes MCP
An MCP server that puts the Diomedes wiki in Claude's hands: search, read, write, move, comment on, and version pages — all through the Diomedes REST API with a single API token.
Pages are exchanged as markdown. Diomedes stores TipTap/ProseMirror JSON, so
this server converts in both directions (src/markdown.js), including mermaid
diagrams (```mermaid), draw.io diagrams (```drawio holding
mxGraph XML), tracker boards (```tracker), callouts (> [!NOTE]),
toggles (<details>), and inline LaTeX ($E = mc^2$).
Every write is a whole-document rewrite — append and prepend re-parse the
existing body too — so the conversion has to be lossless in both directions or
an edit deletes the parts it could not say. It is: see
Round-tripping for what markdown has no syntax for and how it
travels anyway.
A page can also be read and written back as that stored JSON, which is the round trip that loses nothing at all — see Notes and limits.
Draw.io diagrams
Send a diagram as its mxGraph XML and Diomedes renders it exactly as if it had been drawn in the UI — double-clicking it in the page opens the draw.io editor:
```drawio
<mxfile host="app.diagrams.net">
<diagram name="Page-1">
<mxGraphModel dx="800" dy="600" grid="1">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="2" value="Start" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="120" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
```The fence label is a courtesy, not a requirement: any fence whose body starts
with <mxfile> or <mxGraphModel> is treated as a diagram, as is bare mxGraph
XML sitting in the markdown with no fence at all. An ```xml fence
holding anything else is still an ordinary code block. Diomedes generates the
preview image itself on first render, so only the XML travels over the wire.
Diagram fences are matched leniently, because a diagram that lands as a code
block is dead on the page — it cannot render, enlarge or be edited. The language
is case-insensitive, may carry attributes (```mermaid title="flow") or
use the mmd alias, and a fence with no language at all is read as a diagram
when its body opens with a mermaid declaration (graph TD, sequenceDiagram,
erDiagram, …). Draw.io answers to drawio, draw.io, mxgraph and mxfile
on the same terms, and needs no declaration heuristic at all — mxGraph XML names
itself in its document element.
Round-tripping
The editor's schema has more in it than markdown has syntax for, and the gap is where content used to disappear. A write is always a whole-document rewrite, so anything the markdown could not carry was deleted by the next edit — an uploaded PDF, a drawing, a pasted video, a merged table cell, five kinds of formatting.
Blocks markdown cannot spell
Excalidraw drawings, iframe embeds, videos, YouTube videos and uploaded documents have no markdown syntax at all. They travel as HTML comments carrying the block's own attributes:
<!-- diomedes:documentBlock {"attachmentId":"9f1c…","url":"/api/attachments/9f1c…/inline","filename":"Q3 forecast.pdf","mime":"application/pdf","size":903211,"kind":"pdf"} -->That is enough to rebuild the node, so reading a page and writing it back keeps
the block exactly as it was. Keep those comments in whatever you write back;
deleting one is the way to remove the block on purpose. The JSON is the node's
attributes verbatim, so an Excalidraw comment is as long as the drawing is
complicated — that is the price of not losing it. A -- inside the JSON is
written as -\u002d, because -- cannot appear inside an HTML comment and a
drawing is free to contain an arrow; JSON.parse undoes it on the way back.
A tracker board is the one block still without a carrier — that is its own piece of work — so a write does delete it. It is counted in the loss report all the same, because a loss the result names is a bug and a loss it stays quiet about is a trap.
diomedes_update_page compares the blocks the page had against the blocks it is
about to write and names anything that went, in every mode. The older
<!-- embed: … --> / <!-- video: … --> / <!-- youtube: … --> placeholders,
which carried only a URL, are still read — markdown from an earlier read is
exactly the markdown that would otherwise delete the block it names.
Marks, breaks and tables
| In the editor | In markdown |
|---|---|
| Underline | <u>text</u> |
| Highlight | ==text==, or <mark data-color="#ffd43b">text</mark> when coloured |
| Superscript · subscript | <sup>2</sup> · <sub>n</sub> |
| Text colour | <span style="color: #e03131">text</span> |
| Hard break | two trailing spaces, or <br> on the way in |
| Column alignment | :---, :---:, ---: in the divider row |
| Merged cells | an HTML <table> with colspan / rowspan |
| Column widths | <!-- diomedes:tableColumns [220,null] --> under the table |
A table with no merged cells stays a pipe table, because that is the form worth
reading and editing; only colspan/rowspan force the HTML form, and the pixel
widths ride underneath rather than inside. Images are emitted as their own
block, never inside a paragraph or a heading — image is a block node in this
schema, and a document that nests one inside either is rejected outright.
Filling markdown's gaps with HTML gives prose a way to be mistaken for
formatting, so prose that opens one of these spellings is escaped: a paragraph
that says <u> comes back as \<u>, and ==this== as \==this==. Only the
exact openings are escaped — an ordinary 5 < 10 is left as it is — and the
backslash is gone again by the time the text reaches the page.
Math
Diomedes renders inline LaTeX and nothing else. Its math extension is a
decoration painted over ordinary text rather than a node in the schema, and the
regex behind it (/\$([^\$]*)\$/gi) cannot cross a dollar sign — so a $$…$$
pair captures the empty string, draws nothing at all, and leaves the formula on
the page as literal dollar signs.
A math span is therefore opaque here, in both directions: parsed and written
back verbatim, with no backslash unescaping, no emphasis and no escaping.
$\times$ stays $\times$ instead of decaying to $times$, and $a_1 + b_2$
keeps its subscripts instead of coming back as $a*1 + b*2$ wearing an italic
mark. Display math is normalised to the form that renders: $$E = mc^2$$,
$$$E = mc^2$$$ and the three-line
$$
E = mc^2
$$block all become $E = mc^2$. That loses the centred layout, and it is still
strictly better than a formula that sits on the page as text.
A span is $…$ around content holding no $ and no newline, with at least one
non-space character, no space against either delimiter, and no digit
immediately after the closing one — so it costs $5 and $10 is prose, not a
formula, and so is $5 and$10. The doubled form is the exception to the
space rule: $$ E = mc^2 $$ is a formula, because nothing else writes that,
and its content is trimmed. A dollar sign outside a span is written \$ on the
way out, which keeps it literal and stops the app's own renderer swallowing
$5 and $ into a KaTeX span. Math inside backticks or a fence is left entirely
alone — the fold that rewrites a display block reads fences on exactly the
terms the fence parser does, so a ```markdown example quoting a fence
of its own is still source.
Backslashes must be doubled in a tool call, because the call is JSON:
"$\\alpha$" on the wire is $\alpha$ on the page. A single backslash is read
as a JSON escape instead — $\times$ arrives as $⇥imes$, a tab that ate its
own t — and $\alpha$ makes the call fail outright, since \a is not a JSON
escape at all. Bodies sent by file_path are read from disk and need no such
doubling.
Tracker boards
A ```tracker fence becomes a kanban board on the page — a real
trackerBoard block with draggable cards, not a code block:
```tracker
# Release 3.4
## Backlog
- Write the changelog
Mention order keys.
- Tag it
## Doing
- Ship the board
```# Title captions the board and is optional, ## Name opens a column, - Card
adds a card, and a line indented under a card is that card's note. A card with
no title is a bare -. ```board and ```kanban mean the same
thing, on the same case-insensitive terms as the diagram fences.
If a card note itself contains a ``` line, open the board with four
backticks — three would close the fence at that line and the rest of the board
would land on the page as loose paragraphs. Boards this server writes already
do that for you; this is only a rule for boards you type.
What the fence deliberately does not carry is column colour and the ids
of columns and cards. The format exists so that a person or a model can type a
whole board in one go, and ids would destroy that property. The consequence is
that a board does not survive a markdown round trip intact: every mode of
diomedes_update_page re-serialises the whole page, so a board comes back with
every column gray and every card re-identified even when the edit was to a
paragraph somewhere else on the page. The tool says so in its result when it
happens.
So the fence is how you write a board, and the diomedes_tracker_* tools
are how you edit one — they work on the page's JSON, where the colours and
ids live.
Setup
Create a token in Diomedes under Settings → API tokens (the plaintext is
shown once). The token acts as the user who created it — same role, same space
scoping. OAuth is not in this release: every client authenticates with that
token, sent as Authorization: Bearer dio_….
The hosted server is at https://app.diomedes.app/mcp. Each request carries its
own token, so one deployment serves every user, each acting as themselves.
Claude Code
claude mcp add --transport http diomedes https://app.diomedes.app/mcp \
--header "Authorization: Bearer dio_xxxxxxxx"Add --scope user to make it available in every project.
Cursor
.cursor/mcp.json (project) or ~/.cursor/mcp.json (global):
{
"mcpServers": {
"diomedes": {
"url": "https://app.diomedes.app/mcp",
"headers": { "Authorization": "Bearer dio_xxxxxxxx" }
}
}
}Codex
~/.codex/config.toml:
[mcp_servers.diomedes]
url = "https://app.diomedes.app/mcp"
http_headers = { Authorization = "Bearer dio_xxxxxxxx" }Claude Desktop
Claude Desktop reaches remote servers through Settings → Connectors → Add
custom connector with the URL https://app.diomedes.app/mcp. Where a
connector cannot carry a header, use the stdio fallback below in
~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"diomedes": {
"command": "npx",
"args": ["-y", "diomedes-mcp"],
"env": {
"DIOMEDES_TOKEN": "dio_xxxxxxxx",
"DIOMEDES_BASE_URL": "https://app.diomedes.app"
}
}
}
}Restart Claude Desktop afterwards.
Stdio fallback
The same server runs locally over stdio, one token per process:
claude mcp add diomedes \
--env DIOMEDES_TOKEN=dio_xxxxxxxx \
--env DIOMEDES_BASE_URL=https://app.diomedes.app \
-- npx -y diomedes-mcp| Variable | Required | Default | Notes |
|---|---|---|---|
| DIOMEDES_TOKEN | yes | — | dio_… API token |
| DIOMEDES_BASE_URL | no | http://localhost:3000 | The Diomedes API the server talks to |
| DIOMEDES_TIMEOUT_MS | no | 30000 | Per-request timeout |
Verify the wiring without an MCP client:
DIOMEDES_TOKEN=dio_xxx DIOMEDES_BASE_URL=https://app.diomedes.app \
npx @modelcontextprotocol/inspector node src/index.jsRunning the HTTP transport
src/http.js (bin diomedes-mcp-http) is the hosted entry point: a plain
node:http server speaking Streamable HTTP on POST/GET/DELETE at
MCP_PATH, with GET /healthz for the load balancer. There is no token in its
environment — the bearer on each request is the token, and each session gets
its own client for it. A request without a bearer is answered with 401, a JSON
body and a WWW-Authenticate: Bearer header. A session starts with a POST
initialize request, streams on GET with its Mcp-Session-Id, and ends with
DELETE, which drops it from the table /healthz counts. A GET with no
session id — a browser, or a proxy probe — gets a 405 JSON body with a hint
saying what the endpoint is and where to point a client.
| Variable | Default | Notes |
|---|---|---|
| PORT | 8788 | Listen port |
| DIOMEDES_BASE_URL | http://localhost:3000 | The Diomedes API; in the hosted deployment, the app's own origin |
| MCP_PATH | /mcp | Path the transport is served at |
| DIOMEDES_TIMEOUT_MS | 30000 | Per-request timeout towards Diomedes |
The Dockerfile builds it (node:22-alpine, production dependencies only,
the whole of src/ so the sync CLI is in the image too). Every push to main
publishes it as ghcr.io/gagewillette/diomedes-mcp:latest and :sha-<short>,
and a v* tag adds :<tag> (.github/workflows/publish.yml; the tests run
first, so a red main publishes nothing).
The Diomedes app reverse-proxies /mcp to this service itself, so the pairing
is two services and one variable each way — this one is told where the app's
API is, and the app is told where to send /mcp:
# docker-compose.yml, beside the app
services:
diomedes:
image: diomedes/app
environment:
DATABASE_URL: postgres://…
MCP_UPSTREAM_URL: http://diomedes-mcp:8788
diomedes-mcp:
image: ghcr.io/gagewillette/diomedes-mcp:latest
environment:
DIOMEDES_BASE_URL: http://diomedes:3000
depends_on: [diomedes]Whatever sits in front must pass the Authorization header through and leave
responses unbuffered: the transport answers some requests as a
text/event-stream. GET /healthz is what to point a readiness check at.
Tools
Every space argument accepts a UUID, slug, or name; page and version
arguments are UUIDs, which search and listing tools always print.
Finding things
| Tool | Does |
|---|---|
| diomedes_whoami | Which user the token acts as, in which workspace, at what role |
| diomedes_list_spaces | Spaces the token can see, in sidebar order, with role and page count |
| diomedes_space_stats | One space's size and shape: pages, members, files, versions, comments |
| diomedes_list_pages | One space's page tree as an indented outline |
| diomedes_search_pages | Full-text (+ semantic, if enabled) search with snippets — 25 matches at most; verified_only and kind narrow it |
| diomedes_recent_pages | The 16 most recently updated pages |
| diomedes_list_favorites | Starred pages |
| diomedes_pages_with_code | The pages in a space whose bodies hold code blocks or backticked paths, with counts — the first step of a drift check; reads every body, since search returns none |
Spaces, access, and people
| Tool | Does |
|---|---|
| diomedes_create_space | A new space in this workspace (workspace admin or owner only) |
| diomedes_update_space | Rename, or change description, icon, or workspace-wide access |
| diomedes_delete_space | Destroy a space and everything in it, permanently |
| diomedes_reorder_spaces | Set the sidebar order — this account's own, nobody else's |
| diomedes_list_members | Who is named on a space, and whether it is open to the workspace |
| diomedes_set_space_access | Grant, change, or remove one person's access to a space |
| diomedes_list_users | People in this workspace, with the ids set_space_access takes |
Reading and writing
| Tool | Does |
|---|---|
| diomedes_read_page | Page body as markdown or raw TipTap JSON, its outline, or one section or block — with the page's kind, owner and verification in the header |
| diomedes_read_subtree | A page and everything under it, in one request |
| diomedes_create_page | New page in a space, optionally nested, with a markdown body and a kind |
| diomedes_edit_page | Change one section or one block, leaving the rest of the page untouched |
| diomedes_replace_text | Find and replace a term across one page or a hundred |
| diomedes_update_page | Rewrite the whole page: title, icon, kind, and replace, append, or prepend markdown — or write a TipTap document back as json. Warns when a replace overwrote more than 12 blocks |
| diomedes_bulk_write_pages | Create/update many pages at once, reading bodies from local .md files |
| diomedes_resolve_links | Re-check the [[links]] this session wrote that named a page which did not exist yet |
| diomedes_move_page | Re-parent, reorder, or move a page and its subtree to another space |
| diomedes_move_many_pages | Move a whole selection at once, landing in the order given |
| diomedes_delete_many_pages | Trash a selection, subpages included, in one statement |
| diomedes_delete_page | Move a page and its subtree to the trash |
| diomedes_restore_page | Bring a page back from the trash (writer) |
| diomedes_list_trash | What's in a space's trash (writer) |
Verification and ownership
| Tool | Does |
|---|---|
| diomedes_verify_page | A human marks the page verified, optionally for interval_days before it expires |
| diomedes_unverify_page | Withdraw that mark |
| diomedes_set_page_owner | Name the person responsible for a page, by username, name or id |
A page has a kind — decision, runbook, postmortem, onboarding,
concept, reference, meeting or other — and a verification state:
verified, expired, needs_review or unverified. Any write through an API
token puts a verified page into needs_review, so what an agent writes is
never verified until a person has read it. diomedes_read_page prints all three
under the header, diomedes_search_pages filters on the first two, and
diomedes_list_versions says whether each version came from the editor, a
token, an import, a restore or a duplicate. On a server without the
feature, none of those lines appear and the tools return the server's own error.
Links
| Tool | Does |
|---|---|
| diomedes_list_backlinks | The pages that point at this one |
| diomedes_list_outgoing_links | The pages this one points at, unresolved links included |
Attachments
| Tool | Does |
|---|---|
| diomedes_upload_attachments | Upload local files to a page and get the url each is served at |
| diomedes_read_attachment | Fetch a stored file back — to a local path, or inline if it is text |
Tracker boards
| Tool | Does |
|---|---|
| diomedes_tracker_list | Boards on a page: position, block id, title, columns and card counts |
| diomedes_tracker_read | One board in full as JSON, with every column and card id |
| diomedes_tracker_create | Add a board to a page, columns, cards and colours given up front |
| diomedes_tracker_edit | A list of operations, applied in order and written as one edit |
| diomedes_tracker_delete | Remove a board from a page |
History, comments, sharing
| Tool | Does |
|---|---|
| diomedes_list_versions · diomedes_read_version · diomedes_restore_version | Page history |
| diomedes_list_comments · diomedes_add_comment · diomedes_resolve_comment | Comment threads |
| diomedes_flag_gap | A page-level comment prefixed Gap: — what the page should cover and does not, without editing it |
| diomedes_favorite_page | Star / unstar |
| diomedes_share_page | Create or revoke a public read-only link |
Prompts
Each takes a brief plus an optional space and parent, and walks the model
through the same steps: search for an existing page first, read two neighbouring
pages for house style, create the page with the right kind under the parent, and
end by reporting the page id and saying it is unverified until a human marks it.
| Prompt | Writes |
|---|---|
| diomedes_write_doc | A page on a topic, filed in the right space |
| diomedes_adr | A decision: status, date, context, decision, consequences |
| diomedes_postmortem | A postmortem: summary, impact, timeline, root cause, what went well, action items as a tracker board |
| diomedes_runbook | A runbook: when to use, preconditions, numbered steps with code, rollback, owner |
| diomedes_service_readme | A reference: purpose, how to run, configuration, dependencies, on-call notes |
| diomedes_log_to_knowledge | Takes a transcript (or a file_path this server reads, 2 MB at most) and extracts its decisions and procedures into pages of the right kinds |
diomedes_check_drift is the one prompt that writes no page. Given a space,
an optional path_prefix (the directory of the open repository the pages
describe) and max_pages (default 20), it has the model start from
diomedes_pages_with_code, add the runbook, reference and onboarding
pages search finds, read each by outline then section, and check every
command, path, version and configuration claim against the repository it has
open. Each mismatch becomes one diomedes_add_comment on the offending block,
anchored on the exact phrase and stating what the repository says now; a page
that is missing something rather than wrong gets a diomedes_flag_gap. The
page itself is never edited and nothing is verified or unverified — the report
at the end names the pages checked, the comments left, and the pages it
recommends a human unverify.
Section-scoped editing
Rewriting a whole page to change one line costs the page twice — out of the wiki into the conversation, and back again — and every one of those round trips is a chance to destroy an excalidraw drawing, an embed or a PDF, because markdown has no syntax for them.
diomedes_edit_page addresses part of a page instead. Give it a heading —
which names that heading and everything under it, down to the next heading of
the same or higher rank — or a block id, and say what to do:
{
"page_id": "f0d6f166-…",
"edits": [
{ "op": "replace", "heading": "## Deployment", "markdown": "## Deployment\n\nShip on Tuesdays." },
{ "op": "append", "heading": "Checklist", "markdown": "- announce in #general" },
{ "op": "insert_before", "heading": "## Rollback", "markdown": "## Monitoring\n\nGrafana, board 4." },
{ "op": "delete", "block_id": "blk_01M10H9SCP0EED5AGMD2P7" }
]
}| Op | Where the markdown goes |
|---|---|
| replace | In place of the target. For a section that includes the heading line — write it again to keep it, or write a different one to rename the section |
| append | At the end of the section, after any subsections it contains. A list joins the list already there instead of starting a second one |
| prepend | At the top of the section, immediately under the heading |
| insert_before / insert_after | Outside the target, before or after it — how you add a whole new section beside an existing one |
| delete | Nothing; the target is removed |
Every block the edits do not name is carried across as the identical object: it
is never rendered to markdown and parsed back, so it keeps its exact content,
its blockId, and anything markdown cannot express. Edits are applied in order
and written in one save, and a heading that matches nothing — or matches twice —
fails the whole call before anything is written. dry_run reports what would
change without writing it.
Finding the handles is meant to be cheap too:
diomedes_read_page with format: "outline" lists every heading with its block
id and how much sits under it, section: "## Deployment" returns just that
section, and block_id returns just the one block that id names — which is how
you read part of a page whose headings do not divide it usefully, or at all.
Outline-then-edit costs a fraction of read-then-rewrite.
diomedes_replace_text is the same idea across pages: a literal find and
replace over any number of pages, applied to the text nodes themselves, so only
the blocks that actually match are rewritten. It matches case-sensitively unless
told otherwise, can require whole words, can skip code, and reports the context
of the first few matches on each page. Nothing matching anywhere is reported as
an error rather than a quiet success, and dry_run shows the damage first.
Bulk writes
Writing a documentation tree one diomedes_update_page call at a time means the
whole corpus is read into the model's context and echoed straight back out —
the files are the payload, and the model is an expensive pipe.
diomedes_bulk_write_pages takes paths instead of text. This server opens
the files itself, so the bodies never enter the conversation:
{
"root": "/path/to/docs",
"items": [
{ "page_id": "f0d6f166-…", "file_path": "architecture/backend.md" },
{ "page_id": "8935a7ae-…", "file_path": "api/endpoints.md" },
{ "space": "general", "title": "New page", "icon": "📗", "file_path": "new.md" }
]
}An item with page_id updates that page; an item with space + title creates
one (optionally nested with parent_id). markdown still works inline as an
alternative to file_path.
Push a whole interlinked tree in one call. Every page in the batch is
created before any [[link]] in it is resolved, so a link between two pages in
the same call always resolves — whatever order the items are in, whatever the
concurrency. Give an item a ref and point another at it with parent_ref to
nest a page under one this same call creates, which is what makes a tree a
single call instead of one call per level:
{
"items": [
{ "ref": "arch", "space": "eng", "title": "Architecture", "file_path": "arch.md" },
{ "space": "eng", "title": "Auth Service", "parent_ref": "arch", "file_path": "auth.md" }
]
}ref names an item for the length of one call and nothing else; two items
sharing a ref, a parent_ref naming no item, a cycle, or two items creating
the same title in one space are all refused before anything is written — the
last because a [[link]] to that title could mean either page.
| Option | Default | Notes |
|---|---|---|
| root | cwd | Relative file_paths resolve against it, and may not escape it |
| concurrency | 4 | Pages in flight at once; Diomedes is one process over a 10-connection pool |
| stop_on_error | false | Otherwise a bad item is reported and the rest continue |
| dry_run | false | Read and validate every file and target, write nothing |
Item shape is validated up front, so a malformed manifest fails before anything is written. Per-item failures are reported individually with the file that caused them.
Attachments
diomedes_upload_attachments makes the same trade for binary files: give it
paths, and this server opens each one and posts it as multipart itself, so the
bytes never enter the conversation.
{
"page_id": "f0d6f166-…",
"root": "/path/to/screenshots",
"file_paths": ["login.png", "dashboard.png"]
}Relative paths resolve against root and may not escape it, exactly as in a
bulk write; a bad path is reported on its own line and the other files still go
up. A path that does not exist, names a directory, or holds an empty file is
refused here without being sent — an empty attachment is a mistake often enough
that saying so is more use than storing it. Set convert_to_pdf to have the server turn a .pptx into a PDF on the way
in — that is the one thing the document endpoint can do that the attachment
endpoint cannot, and it is ignored, with a note, for any other file.
The Content-Type is taken from the file extension rather than guessed. Diomedes
serves attachments with X-Content-Type-Options: nosniff, so an image sent as
application/octet-stream is an image the browser quietly declines to draw.
Uploading stores the file and hands back the url it is served at; it does not
put anything on the page. To show it, write that url into the body with
diomedes_update_page:
diomedes_read_attachment goes the other way, and takes the same url — which is
also what an image on a page carries as its src, so a page read is how you find
one. A bare attachment id works too, as long as it is one: ids are UUIDs, and the
route looks them up in a UUID column, so anything else is turned back here rather
than coming back as a database error. With save_to it writes the bytes to a local path; without, a text file up
to 64 KB comes back inline and anything else is refused, because there is nothing
useful to say about the bytes of a PNG in a chat message.
The workspace sets the per-file size limit and whether uploads are on at all. It answers with its own figure when a file is too big, before reading the body.
Reading a whole section
diomedes_read_subtree is one request for a page and every page beneath it,
where the alternative is one request per page — the walk happens in a single
query on the server, so it does not get slower as the tree gets deeper.
It returns the outline by default: titles, ids and nesting, and nothing else.
include_content adds each body as markdown, bounded by max_pages (200) and
max_chars (50000) because a large section is more text than a conversation
usually wants. Bodies are cut in document order and the tool says where it
stopped; the outline is never truncated, so even a partial read leaves every page
id in hand.
Editing a board
diomedes_tracker_edit takes a list of operations rather than being nine tools,
and applies them in order as a single write:
{
"page_id": "0e4e9182-…",
"ops": [
{ "op": "add_column", "name": "Blocked", "colour": "red", "index": 1 },
{ "op": "move_card", "card": "crd_1sh2e1rg", "column": "Blocked" },
{ "op": "add_card", "column": "Doing", "title": "Verify the colours stuck" }
]
}| Operation | Arguments |
|---|---|
| set_title | title |
| add_column | name, colour?, index? |
| update_column | column, name?, colour? |
| move_column | column, index |
| delete_column | column — its cards go with it |
| add_card | column, title, note?, index? |
| update_card | card, title?, note? |
| move_card | card, column?, index? |
| delete_card | card |
A column is addressed by its id, its name (case-insensitively), or its
0-based position, in that order — so a column actually named 2 still wins over
the third column. A column added by an earlier operation can be named by a later
one. A card is addressed by its id only, because two cards may carry the same
title and moving the wrong one is not a mistake anybody notices. A board is
addressed with the optional board argument — a block id, its title, or its
0-based position — which can be left out on a page with one board, which is
nearly every page. Prefer the title or the position: a board's block id is
minted by the server and the app does not yet declare trackerBoard as a block
that keeps one, so a browser save can hand the same board a different id.
One call is one write, for a reason. Every write through an API token replaces the page's stored document, snapshots a version, and resets the collaborative session, which disconnects anyone editing that page in a browser at the time — setting a sprint board up one call per card would do all of that once per card. For the same reason nothing is written unless every operation succeeds: a card id that is not on the board fails the whole call and leaves the page untouched, rather than landing two thirds of an edit nobody can unpick.
Pass if_rev — the revision number the read tools print — to have a write
refused if the page has changed since it was read. It does not make concurrent
editing safe (the reset above still happens), but it turns a silent overwrite
into an error.
Colours are gray, blue, green, yellow, red and violet. The attribute
is spelled the British way, colour, because that is what the app stores;
grey is accepted and stored as gray. Titles are cut at 200 characters and
notes at 500, which is the app's own limit — the result says when it cut
something.
Keep a docs folder in sync from CI
diomedes-sync mirrors a directory of markdown into one space, so the docs
that live beside the code are also the pages the team reads and reviews. A
diomedes.yml at the repository root says where:
space: Engineering # id, slug, or name
parent: Handbook # optional — a page title or id to file everything under
root: docs # directory to mirror (default docs)
kind: reference # default kind for pages it creates (optional)
ignore: # paths under root to leave out; a trailing * is a wildcard
- drafts
- internal/*Every .md file becomes a page titled from its first # heading, or its file
name. A directory becomes a parent page, titled and bodied from its index.md
(or README.md) when it has one and from the directory name otherwise, and the
files under it nest under that page. Page ids are remembered in
.diomedes-sync.json next to the config — commit it — so renaming a heading
updates the page rather than creating a second one, and a file whose contents
have not changed is skipped. A file deleted locally is reported and left on the
wiki; delete it there if it is gone for good.
The CLI ships inside the published image, so it runs anywhere Docker does, with
the repository mounted at /work:
alias diomedes-sync='docker run --rm -v "$PWD:/work" -w /work \
-e DIOMEDES_TOKEN -e DIOMEDES_BASE_URL \
ghcr.io/gagewillette/diomedes-mcp:latest node /app/src/sync.js'
DIOMEDES_TOKEN=dio_xxx DIOMEDES_BASE_URL=https://app.diomedes.app diomedes-sync
diomedes-sync --dry-run # print the plan, write nothing
diomedes-sync --summary-file summary.md # write a markdown summary for a PR comment
diomedes-sync --config path/to/diomedes.ymlFrom a checkout of this repository it is node src/sync.js with the same
flags.
The composite action in action/ runs it from that image — image pins a
different tag, :sha-<short> or :v1.1.0, when a workflow should not follow
latest — and, on pull_request events, posts the summary as a comment on the
PR:
# .github/workflows/docs.yml
name: docs
on:
push:
branches: [main]
paths: ['docs/**', 'diomedes.yml']
pull_request:
paths: ['docs/**', 'diomedes.yml']
permissions:
contents: read
pull-requests: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: gagewillette/Diomedes-MCP/action@main
with:
token: ${{ secrets.DIOMEDES_TOKEN }}
base-url: https://app.diomedes.app
dry-run: ${{ github.event_name == 'pull_request' }}A pull request gets a dry-run comment saying what would change; the push to
main writes it. The runner needs nothing installed beyond Docker, which every
GitHub-hosted runner has; the image is pulled on first use. Pages the sync creates are unverified until someone marks
them, which is the review step.
Notes and limits
- Footnotes round-trip. References and definitions map to the app's own
[^1]/[^1]: bodymarkdown, with four-space continuation for multi-paragraph notes and Obsidian's inline^[note]form on import. Numbering follows citation order, a citation with no definition stays literal text, and ids are minted in the format the app validates. [[Title]]links resolve to real pages. A wiki link is written as the page it names, matched on the whole title (case and whitespace normalized) throughPOST /api/pages/resolve-titles, one request for a whole batch of them. The space being written to wins over the same title elsewhere.[[Title|alias]]and[[Title#Section]]resolve on the title; a markdown link whose href is a page URL (/s/<slug>/p/<uuid>, with or without an origin) becomes a page link outright, no lookup needed. A Diomedes older than that endpoint falls back to/api/pages/link-search, which is an autocomplete and can miss a page that exists — upgrade the server if links come back gray for pages you can see.- Two pages with the same title leave the link gray, and the tool says so, naming the candidates. Pointing it at whichever page was touched last would be a guess, and an invisible one. Markdown carries only a label, so there is nothing to break the tie with.
- A link is resolved again after every write, not just its own. Within one
call this is already settled by construction, but across calls a page can name
one written later. Any page written with a link that missed is remembered and
swept again after each later write in the session — by which time the page it
named may exist. The sweep re-reads each page before rewriting it, so an edit
made in the browser meanwhile is not clobbered; it retries what fails, counts
a link as resolved only once the page carrying it is actually written, and
reports what is still waiting.
diomedes_resolve_linksruns the same pass on demand. A lookup that fails is reported apart from one that came back empty: a title nobody answered about is not a title nobody has used, and telling them apart is what stops a caller writing a second copy of a page that exists. - A title matching nothing is still written, as a gray link — the same chip you get by typing a link to a page you have not created yet. The tool result names it. In the app the gray link is clickable: it opens the page picker, so a link the server could not place can be pointed at a page by hand. It also resolves on its own the day a page carries that title.
- Math is inline
$…$only, and$$is normalised to it — see Math. Escaping is preserved wherever the escape is what distinguishes prose from a formula, which covers the case it exists for:\$5 and \$10survives as written. It cannot survive\$x\$, because that decodes to the text$x$, and a page stores math as exactly those characters — nothing downstream, this parser or the app's renderer, can tell it from a formula. - Unknown arguments are errors, not silence. Every tool schema is strict, so
calling
diomedes_create_pagewithbodyinstead ofmarkdowncomes back asUnrecognized key: "body"rather than creating an empty page. The same goes for the items inside adiomedes_bulk_write_pagesmanifest, where one misspelled key would otherwise repeat across the whole batch. diomedes_move_pagewill not reparent a page you only meant to reorder. Passingparent_id: nullmoves a page to the space root; omitting it leaves the page under whichever parent it already has. That distinction cannot be made in the request alone —POST /api/pages/:id/movedestructures{ parentId = null }, so a body with no parent and a body with an explicit null are the same request to it — so a move that names no parent reads the page first and states the parent it already has. That costs one extraGETon a call that is rare, and it stays correct if the route later learns to leave an absentparentIdalone. The exception is a cross-space move: the old parent belongs to the space being left, and the server refuses a parent outside the destination, so naming no parent there means the destination's root — which is whatdiomedes_move_many_pagesmeans by it too.- Pages nest up to 20 levels deep (
MAX_PAGE_DEPTHin the app), subtree included: a move is refused when the deepest page it carries would land past that. It used to be one level, and some older tool text said so. - Callouts are
> [!NOTE]. The variants the editor renders areNOTE,INFO,SUCCESS,WARNINGandDANGER; anything else falls back to info. A callout carries no title of its own, so give it a bold first line. - The blocks markdown cannot spell survive as comments. Excalidraw
drawings, iframe embeds, videos, YouTube videos and uploaded documents come
back from
diomedes_read_pageas<!-- diomedes:type {…} -->comments carrying the node's own attributes, and are rebuilt from them on the way in — see Round-tripping. Keep the comment and the block is kept.diomedes_update_pagenames anything a write dropped, in every mode, by comparing the page it read against the content it is about to write; usediomedes_restore_versionif that was not what you meant. - What markdown still cannot carry. A
justifytext alignment has no divider spelling and comes back unaligned, and a paragraph's or heading's own alignment outside a table is not written at all. A table cell holding more than one paragraph comes back as one — a pipe table has no way to say otherwise. A hard break inside a heading becomes a space, because a heading is one line. None of these is reported: the loss report covers the blocks that travel as comments, which are the ones whose loss is measured in someone's work rather than in a pixel. - Permissions are the token's. A reader token can read, and nothing else:
commenting needs the
commenterrole, writing needswriter, and the trash —diomedes_list_trashanddiomedes_restore_page— needswriteras well, since it is part of a space's write surface. The server surfaces Diomedes' 403 message verbatim. The ladder isreader(read),commenter(read and comment),writer(read, comment, and create, edit and trash pages) andadmin(all of that, plus managing the space's members, settings and public access). - Some lists are the server's size, not yours.
diomedes_recent_pagesis 16 pages and takes no argument, and a search is capped at 25 matches with no limit forwarded —diomedes_search_pages' ownlimittrims that answer rather than asking for a larger one. A search that comes back full says so, so a truncated list is never reported as a complete one. - A token is pinned to one workspace. Diomedes resolves the workspace from
the token itself and never reads a workspace header, so there is no workspace
argument here and adding one would be inert. The consequence worth knowing is
what a page id from another workspace looks like: a plain 404, identical to an
id that never existed, because a space in another workspace does not resolve
to anything at all. The 404 says so rather than only offering "deleted", and
diomedes_whoaminames both the pinned workspace and the others the account belongs to — reaching one of those needs a token minted there. - Renaming a space repairs its own links. The slug follows the name, and in the same transaction Diomedes rewrites every link in the workspace that points into the space and files the old slug as an alias, so bookmarks and exported documents keep resolving. There is nothing to fix up afterwards. What it does cost is a second or two of a wait screen for anyone in the space while the address is swapped; renames that do not move the slug, and description, icon and public-access changes, skip that entirely.
- "Public" on a space means the workspace, not the internet.
public_rolehands that role to everyone in the workspace who is not already a named member, and a named membership overrides it in either direction. The tool that publishes to the internet isdiomedes_share_page, one page at a time. - Deleting a space is the one delete with no trash behind it. Every page,
version, comment, attachment, share link and membership goes with it by
cascade and cannot be restored, so
diomedes_delete_spaceasks for the space's exact name inconfirm_nameas well as a reference to it.diomedes_delete_pageis the reversible one. - A loose reference to a space or a person is refused when it fits more than
one. Space and user arguments take an id, a slug or username, or a name, and
a name that is not an exact match still resolves if exactly one name contains
it. If two do, the tool lists them and does nothing: renaming a space swaps its
address behind a lock, deleting one cascades with no trash, and granting the
wrong person access is not a mistake that announces itself. An exact id, slug
or name always wins outright, so
Opsis not made ambiguous byDesign Ops. - Sidebar order is private.
diomedes_reorder_spacessets the order for the account the token belongs to and nobody else, and it is not a permission — a reader may reorder a space just as an admin can. The underlying endpoint insists on the complete visible list, so the tool fills in every space the caller did not name, keeping their current relative order. - A tracker board survives markdown, but not intact. The
```trackerfence carries the board's title, columns, cards and notes, and nothing else — no colours, no ids. Every mode ofdiomedes_update_pagere-serialises the page, so any write through markdown resets a board's colours and re-mints its ids even when the board was not the thing being edited. Bothupdate_pageandbulk_write_pagessay so when the page holds a board. Thediomedes_tracker_*tools go through the page's JSON instead and keep both. - The tracker tools are not concurrency-safe, only concurrency-honest. Any
write through an API token replaces the page's stored document and resets its
collaborative session, disconnecting anyone editing that page in a browser and
costing them up to two seconds of unflushed typing.
if_revmakes a collision detectable, and one batched call is better than eleven, but neither makes it safe to edit a board someone is looking at. - A fence is as long as it needs to be. A code block, a diagram or a card
note whose own text contains a
```line is written with a four-backtick fence, because three would close the block at that line and strew the rest of the page out as loose paragraphs — page-level damage from a read-then-write that changed nothing. Every markdown reader accepts the longer marker. - Block ids survive a write. Diomedes gives every block a stable
blk_…id, and a comment resolves against the block it was written on before it falls back to searching the page for the words it quoted. Markdown has nowhere to put an id, so a page rewritten from markdown used to arrive anonymous, be given an entirely new set, and take every comment on the page off its anchor. The ids are now re-attached on the way out (src/blockIds.js) by matching the rewritten document against the one that was read: a block that renders to the same markdown keeps its id outright, wherever it now sits, and what is left over is matched inside the gap between two of those, against a block of the same type that it still reads like. So an edited paragraph keeps its id, an inserted one is left for the server to name even when it landed in the edited one's place, and a deleted one takes its id with it. The tool result says how many were kept. Two cases still lose an id, both deliberately: a block markdown cannot represent at all, because it does not come back, and a block whose text was replaced outright rather than edited — the comment on it could not have anchored inside the new words anyway, so a wrong id would buy nothing. jsonis the write that loses nothing.diomedes_read_pagewithformat: "json"returns the stored TipTap document, anddiomedes_update_pagetakes exactly that back as itsjsonargument (the fenced block it was printed in is fine). Ids stay identical, and excalidraw and embed blocks survive, because nothing is converted in either direction. It is several times larger than the markdown, so read markdown for anything you mean to read or rewrite yourself, and keep this path for edits that must not lose anything.jsonreplaces the whole body;appendandprependare markdown's. The document has to hold at least one block — the editor's schema isblock+, so an empty one is a page the browser cannot open; clear a page with a single empty paragraph instead.- A token write still resets the page's live document. Diomedes drops the collaborative CRDT on any write made with an API token and reseeds it from the JSON just stored, so anyone with the page open in a browser is moved to the version this server wrote. That is server-side behaviour this server cannot opt out of; it is worth knowing before writing to a page someone is editing.
appendandprependare not rewrites. Both splice the new blocks into the stored document and never put an untouched block through the converter, so they are safe on any page whatever the round trip would have done to it. The result says how many blocks they left alone.replaceis the mode that rewrites.- Sections are top-level blocks. A heading inside a list item, a quote, a
callout or a code fence is not a section and cannot be targeted — which is
what stops a
# deploycomment in a bash sample cutting a page in half. Target the block that contains it by id instead. A section runs to the next heading of the same or higher rank, so editing## Deploymentcovers the### Rollbackunder it, and appending to## Deploymentlands after that subsection rather than before it. - Deleting a section leaves its footnotes. The apparatus belongs to the document rather than to any section, so a definition whose only reference was in the deleted text stays on the page as an uncited note. That is deliberate: silently dropping prose nobody asked to remove is the worse failure.
- Permissions are the token's. A reader token can read and comment but not write; the server surfaces Diomedes' 403 message verbatim.
- Tokens cannot mint tokens, so token management stays in the web UI.
- Attachments move by path, not by value. Upload and download both name a
local file and let this server do the reading and writing, so a binary never
has to be described in the conversation. There is no endpoint that lists a
page's attachments, so the way to find one already in place is to read the
page and take the url out of its
. - Backlinks are the question to ask before a rename. A link that carries the
target's page id — which is what this server writes whenever it can resolve a
[[Title]]— follows a rename on its own. A link matched by title alone detaches the moment the title changes, anddiomedes_list_backlinksis what tells you which pages that is about to happen to.
Development
npm test # stub Diomedes API + real MCP clients over stdio and HTTP, plus markdown round-tripsLayout: src/server.js (every tool and prompt, registered by
createServer(client)), src/index.js (stdio entry, one token from the
environment), src/http.js (Streamable HTTP entry, a token per request),
src/client.js (HTTP, multipart and
raw transfers, space resolution), src/markdown.js (markdown ↔ TipTap JSON,
footnotes included), src/customBlocks.js (the comment that carries a block
markdown cannot spell), src/links.js (pointing [[Title]] at the page it
names), src/blockIds.js (keeping block ids attached across a write),
src/bulk.js (file reading and concurrency for bulk writes),
src/sync.js (the diomedes-sync CLI), src/verification.js (page kinds and
the verification lines read_page prints), src/drift.js (what
pages_with_code counts as code and as a path),
src/files.js (attachments in and out, reusing bulk's path confinement),
src/board.js (the ```tracker fence), src/tracker.js (the board model,
and the operations the tracker tools apply to it) and src/sections.js (addressing
and splicing part of a document, without putting the rest through the converter).
The markdown layer tracks the app's schema deliberately: test/markdown.test.js
carries the footnote cases from the app's own footnoteMarkdown.test.js, so a
change to how the editor reads a footnote shows up as a failure here.
test/roundtrip.test.js goes the other way — it builds a document per node
type, exactly as the editor builds it, sends it through
toMarkdown → fromMarkdown, and asserts deep equality. That is the direction
that loses things, because every write is a whole-document rewrite. A new node
type in the editor belongs in that file the day it lands.
