npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

diomedes-mcp

v1.1.0

Published

MCP server for the Diomedes wiki — read, search, and write pages over the Diomedes REST API.

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.js

Running 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:

![Login screen](/api/files/b7f08f11-…/login.png)

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.yml

From 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]: body markdown, 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) through POST /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_links runs 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 \$10 survives 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_page with body instead of markdown comes back as Unrecognized key: "body" rather than creating an empty page. The same goes for the items inside a diomedes_bulk_write_pages manifest, where one misspelled key would otherwise repeat across the whole batch.
  • diomedes_move_page will not reparent a page you only meant to reorder. Passing parent_id: null moves 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/move destructures { 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 extra GET on a call that is rare, and it stays correct if the route later learns to leave an absent parentId alone. 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 what diomedes_move_many_pages means by it too.
  • Pages nest up to 20 levels deep (MAX_PAGE_DEPTH in 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 are NOTE, INFO, SUCCESS, WARNING and DANGER; 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_page as <!-- 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_page names anything a write dropped, in every mode, by comparing the page it read against the content it is about to write; use diomedes_restore_version if that was not what you meant.
  • What markdown still cannot carry. A justify text 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 commenter role, writing needs writer, and the trash — diomedes_list_trash and diomedes_restore_page — needs writer as well, since it is part of a space's write surface. The server surfaces Diomedes' 403 message verbatim. The ladder is reader (read), commenter (read and comment), writer (read, comment, and create, edit and trash pages) and admin (all of that, plus managing the space's members, settings and public access).
  • Some lists are the server's size, not yours. diomedes_recent_pages is 16 pages and takes no argument, and a search is capped at 25 matches with no limit forwarded — diomedes_search_pages' own limit trims 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_whoami names 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_role hands 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 is diomedes_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_space asks for the space's exact name in confirm_name as well as a reference to it. diomedes_delete_page is 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 Ops is not made ambiguous by Design Ops.
  • Sidebar order is private. diomedes_reorder_spaces sets 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 ```tracker fence carries the board's title, columns, cards and notes, and nothing else — no colours, no ids. Every mode of diomedes_update_page re-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. Both update_page and bulk_write_pages say so when the page holds a board. The diomedes_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_rev makes 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.
  • json is the write that loses nothing. diomedes_read_page with format: "json" returns the stored TipTap document, and diomedes_update_page takes exactly that back as its json argument (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. json replaces the whole body; append and prepend are markdown's. The document has to hold at least one block — the editor's schema is block+, 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.
  • append and prepend are 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. replace is 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 # deploy comment 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 ## Deployment covers the ### Rollback under it, and appending to ## Deployment lands 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 ![alt](/api/files/…).
  • 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, and diomedes_list_backlinks is 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-trips

Layout: 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.