Skip to content

feat: the daemon serves an HTTP API, and each room asks for what it shows - #64

Open
ceounittt wants to merge 1 commit into
mainfrom
feat/mcp-http-api
Open

feat: the daemon serves an HTTP API, and each room asks for what it shows#64
ceounittt wants to merge 1 commit into
mainfrom
feat/mcp-http-api

Conversation

@ceounittt

@ceounittt ceounittt commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

The ask was to wire the visualizer up properly: make the MCP server a normal
HTTP router, with MCP handles. It already was an HTTP router — axum, two
transports, a bearer middleware. What it had was no API. So this is the
narrower, sharper reading of that ask: the same verb set over a second
transport
, so something that is not an MCP client can reach it.

The rule

A route is a verb.

route verb
GET /v1/export export — the whole graph, for the galaxy
GET /v1/board?initiative=&when=&columns= board
GET /v1/at?id=&when= at
GET /v1/chain?id= read_chain

Nothing invents a vocabulary the curator API does not already have. No
POST /call/{verb} tunnel either — an RPC tunnel cannot be cached, cannot be
opened in a browser, and reads as noise in a log.

Three things are fixed in the shape rather than left to each handler, all
free now and expensive to retrofit: /v1/ on every path, a Principal on
every handler (one variant today, carrying nothing — that is the point), and
egress on the way out so redaction and the operator's initiative ceiling
live in one auditable place.

What this fixes

/graph.json was one answer to one question: everything. Two consequences.

The board was showing columns that were not the initiative's. The export
selects a node's name, tags, body and the rest — not properties — and the
column registry lives in the Board node's properties. So the browser drew a
built-in open / in progress / done whatever an initiative had configured.
On my own vault an initiative had added a fourth column, and no client but the
chat could show it.

Every room paid the galaxy's price for text. The export truncates bodies,
so the only route to the real thing was ?bodies=true — every body in the
vault.

Measured against a copy of a real vault:

reader, opening a three-step trail   3.7 MB  ->  6.3 KB   (one request)
board, opening one card              3.7 MB  ->  1.4 KB
column registry, 19 initiatives       130 KB  ->  3.6 KB
one node's body                    241 chars ->  1229 chars

Permission

Node-addressed verbs have a problem the others do not: the export judges the
ceiling per node as it builds and a board is named by the request, but a node
id says nothing about who may read it. reaches_node asks the junction, and
one reachable initiative is enough — the same rule the export applies. A node
attached to nothing is unreachable, deliberately.

Checked by narrowing the ceiling to a single initiative: a node outside it
answers 404 from /v1/at, its board answers 404, the export narrows to the 43
nodes that remain. Three verbs, one rule. 404 rather than 403 throughout —
"exists, but not for you" is still telling you it exists.

A chain step outside the ceiling is dropped rather than redacted. Redaction
says "there is something here you may not read"; for a trail the honest answer
is that this line of work was never shared.

Nothing here requires the API

A baked snapshot has no daemon; an older daemon has no routes. Every call
degrades: /v1/export/graph.json (still served as an alias), /v1/board
→ the built-in vocabulary, /v1/at → the whole-bodies fetch.

Which path to take is decided once, by the only probe that can tell the
difference: /v1/at with no id is a 400 where the route exists and a
404 where it does not. Every other probe confuses "no such node" with "no
such endpoint", because both answer 404.

Verified end to end against a daemon without the API: every call 404s, the old
paths answer, and the drawer shows the same text it did before.

Three interface faults surfaced on the way

All of them older than this branch.

  • The console cut off its own dropdowns. #panel carries
    overflow-x: auto, and CSS will not let one axis scroll while the other
    stays visible — overflow-y computes to auto alongside it, so a menu
    opening upward sits outside the box doing the clipping. Measured on the same
    element: laid out 300px tall either way, one option of nineteen painted. Now
    position: fixed, placed from script, and it stops short of the window's
    edge as well as growing past the panel's.

  • The rail was the height of the window, and swallowed drags. Anchoring
    both top and bottom made twenty-eight projects a wall down the right
    side; it is as tall as its content now (336px instead of 708), capped at
    twelve whole rows — measured from where a row ends, because the first
    row's margin is not part of its height. Separately, a drag starting on the
    rail's own background did not turn the galaxy: it swept a text selection
    across the page, 337 characters from the gap under the search box.
    pointer-events: none with the controls taking their events back, and
    user-select: none on the chrome.

  • Two scrollers still had the browser's default bar. The treatment was
    written per scroller, so anything added later missed it. One rule now, on
    * rather than :root, because scrollbar-color inherits and
    scrollbar-width does not.

Testing

218 workspace tests green, 10 of them new in api::egress (the ceiling, the
glob suffix, deny only ever adding, redaction). The rest of the verification
is above and was done against a copy of a real vault with a daemon on a
scratch port — the live one was not touched.

clippy and rustfmt are not installed in my toolchain, so neither ran here.

Not in this PR

set_status and link — the writes. That is where the auth token stops being
optional: auth.rs is off by default today because the daemon is on loopback
and everything is read-only. Also missing: an ETag on the export, which is
cheaper than any restructuring for the one read that genuinely wants the whole
graph.

…hows

kaeru-mcp has spoken HTTP since it grew a streamable transport; what it has
not had is an API. The MCP transports carry JSON-RPC, which only a client
that speaks MCP can use, so the visualizer was handed a side door —
`/graph.json` — that corresponds to no verb and grew its own config, its own
env vars and its own CORS rule. Every room then lived on that one answer,
and the answer was "everything".

The rule here is that **a route is a verb**. `/v1/board` is `board`,
`/v1/at` is `at`, `/v1/chain` is `read_chain`, `/v1/export` is `export`.
Nothing invents a vocabulary the curator API does not already have, so the
tools and the routes stay two transports over one implementation rather than
two implementations to keep in agreement. No `POST /call/{verb}` tunnel
either: an RPC tunnel cannot be cached, cannot be opened in a browser, and
reads as noise in a log.

Three things are fixed in the shape rather than left to each handler. All
three are free now and expensive to retrofit:

  - `/v1/` on every path.
  - a `Principal` argument on every handler, so a caller identity can grow a
    variant instead of a signature. Today there is exactly one variant and it
    carries nothing; that is the point.
  - `egress` on the way out, so redaction and the operator's initiative
    ceiling live in one auditable place instead of in each handler.

**What the verbs answer that the export could not.** The export selects a
node's name, tags, body and the rest — not `properties` — and an initiative's
column registry lives in the Board node's properties. So the board drew a
built-in *open / in progress / done* whatever an initiative had actually
configured — an initiative that had added a fourth column could not show it
to any client but the chat. The export also truncates every body, because a
document carrying them in full would be unusable; the only way to real text
was `?bodies=true`, which is every body in the vault.

Measured against a copy of a real vault:

    reader, opening a three-step trail   3.7 MB  ->  6.3 KB   (one request)
    board, opening one card              3.7 MB  ->  1.4 KB
    column registry, 19 initiatives       130 KB  ->  3.6 KB   (`columns=true`)
    one node's body                    241 chars  ->  1229 chars

**Permission.** Node-addressed verbs have a problem the earlier ones did not:
the export judges the ceiling per node as it builds, and a board is named by
the initiative in the request, but a node id says nothing about who may read
it. So `reaches_node` asks the junction, and one reachable initiative is
enough — the same rule the export applies. A node attached to no initiative
is unreachable, deliberately: the ceiling is written in initiatives, and an
unfiled node has none to have been opted into. Verified by narrowing the
ceiling to one initiative: a node outside it answers 404 from `/v1/at`, its
board answers 404, and the export narrows to the 43 nodes that remain — three
verbs, one rule. 404 rather than 403 throughout, because saying "exists, but
not for you" is saying it exists.

A chain step outside the ceiling is dropped rather than redacted. Redaction
says "there is something here you may not read"; for a trail the honest
answer is that this line of work was never shared.

**Nothing here requires the API.** A baked snapshot has no daemon and an
older daemon has no routes, so every call degrades: `/v1/export` falls back
to `/graph.json` (still served as an alias), `/v1/board` to the built-in
vocabulary, `/v1/at` to the whole-bodies fetch. Which path to take is decided
once, by the only probe that can tell the difference — `/v1/at` with no id is
a *bad request* where the route exists and a *not found* where it does not.
Every other probe confuses "no such node" with "no such endpoint", because
both answer 404. Verified end to end against a daemon without the API: every
call 404s, the old paths answer, and the drawer shows the same text it did
before.

**Three interface faults surfaced while working on the rooms**, all in things
that had been wrong for a while:

  - *The console cut off its own dropdowns.* `#panel` carries
    `overflow-x: auto`, and CSS will not let one axis scroll while the other
    stays visible — `overflow-y` computes to `auto` alongside it. A menu
    opening upward sits outside the box doing the clipping. Measured on the
    same element: laid out 300px tall either way, but one option of nineteen
    was painted. It is `position: fixed` now, placed from script, and it stops
    short of the window's edge as well as growing past the panel's.

  - *The rail was the height of the window and swallowed drags.* Anchoring
    both `top` and `bottom` made twenty-eight projects a wall down the right
    side; it is as tall as its content now — 336px instead of 708 — capped at
    twelve whole rows, and the cap is measured from where a row ends because
    the first row's margin is not part of its height. Separately, a drag
    starting on the rail's own background did not turn the galaxy: it swept a
    text selection across the page, 337 characters from the gap under the
    search box. The rail is chrome over the galaxy but hit-tested like a
    surface. `pointer-events: none` with the controls taking their events
    back, and `user-select: none` on the chrome — an instrument panel is not
    prose.

  - *Two scrollers still had the browser's default bar.* The treatment was
    written per scroller, so anything added later missed it. One rule now, on
    `*` rather than `:root` because `scrollbar-color` inherits and
    `scrollbar-width` does not.

Steps left on this line of work: `set_status` and `link` — the writes, where
the auth token stops being optional — and an ETag on the export, which is
cheaper than any restructuring for the one read that genuinely wants the whole
graph.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant