v0.1.0 — a Drupal-shaped content store your models can write to
One Cloudflare Worker, one D1 database. A content store shaped like Drupal —
node, vocabulary, term, join table — served to models over MCP, with a web UI,
trigram full-text search, optional embeddings, and OAuth so claude.ai can
connect to it.
One click provisions the D1, runs the migrations, deploys the Worker, and clones
the repo into your own GitHub account so every later push redeploys itself.
The idea
Drupal got the content model right in 2001 and most note tools have been
rediscovering it badly ever since: a node is title + body + datetime + type,
and everything else you want to say about it is taxonomy. That is the whole
schema, plus a join table.
What is new is who does the filing. Tags are the model's output, not a form
field. The create form has no tag input; every tag in the sidebar is a filter
you click; and anything nobody has classified yet collects in an untagged
queue — not a warning, but the agent's work list.
Two refusals visible in that call log, both worth reading. node_tag on a
missing node answers no node with id node_nope — it used to leak
D1_ERROR: FOREIGN KEY constraint failed, which names the storage engine's
difficulty rather than the caller's mistake and which no model can act on; it
was found by photographing the call log for these very docs. And tagging into a
controlled vocabulary answers "Enginering" is not a term in the controlled vocabulary "status". Available: draft, published, review. — which is the
entire reason controlled vocabularies exist here, because a model tagging the
same idea twice produces mcp, MCP and model-context-protocol, and
nothing errors while the taxonomy quietly stops being able to answer "show me
everything about X".
Search says which engine answered
SEARCH "ความจำ" — 1 HIT(S) — MODE: FTS — a match inside a Thai sentence.
FTS5's default unicode61 tokenizer swallows a Thai sentence as a single token
and returns nothing; trigram returns the row. Measured independently four times
across this fleet before anyone wrote it down.
Text search is the default because on a labelled 124k-block corpus it scored
0.765 MRR against bge-m3's 0.099 for known-item retrieval — with the honest
caveat that the probe's ground truth is substring containment, which is exactly
what a trigram index computes. Semantic mode answers the other question. Hybrid
is opt-in because equal-weight fusion measured worse (0.44) than either.
OAuth for claude.ai, a bearer for everything else
claude.ai cannot send a static header, so OAuth is the only door open to it.
Everything else reads a config file and should not have to dance. Three keys,
one gate: oauth, api-token, owner-session.
OAuth 2.1 with PKCE S256 only (plain refused at issue, not merely
unadvertised), Dynamic Client Registration because claude.ai registers itself,
RFC 9728 / 8414 / 9207 discovery, 10-minute single-use codes, 30-day tokens, no
refresh tokens, no client secrets. Codes and tokens are stored as SHA-256
digests — the token is the secret, so a row holding one is as sensitive as the
session it opens.
Storage is three tables in the D1 that is already bound. No KV namespace, so
the one-click install provisions nothing extra.
Auth is opt-in: with no secret set the server is open and /health reports
"auth": "none". A deploy button that produces a Worker returning 401 to its own
owner, with no screen on which to set a secret, is a broken first run.
What is in the box
- 19 MCP tools over one stateless
POST /mcp, echoing the client'sprotocolVersion - A JSON API and a web UI — create, filter, tag cloud, live call log
- The call log as a first-class table, with inputs, results and durations
- Optional embeddings (Workers AI
bge-m3), in their own table keyed by(model, dim) - A Store port with D1 and SQLite adapters — the same app runs under
bun test, on D1, and later under Tauri - 110 tests,
tscclean, verified live on workerd and on production
Scars, documented in place rather than smoothed over
aot: falseis required on Workers. Elysia's AOT compiler builds handlers withnew Function(); workerd forbids it. Every test passed; onlywrangler devcaught it.- D1 returns BLOBs as
number[], bun:sqlite asUint8Array.new Float32Array(numberArray)copies each byte as a float instead of reinterpreting. Nothing throws; every production cosine sat at ~0.00 — a wrong answer wearing the costume of a right one. - An Elysia lifecycle hook inside a plugin is local by default. It would guard the OAuth endpoints, which need none, and leave
/mcpwide open. Removingas: "global"fails 7 tests; without that test it fails none. - A 200 wrapping
isError: truenever triggers an auth prompt. The gate must run at the HTTP layer, before the JSON-RPC handler.
Docs
- Visual tour — every screen, from a real running instance
- Connecting claude.ai
- DESIGN.md — why each decision, and what it cost
MIT. Spun out of digger-oracle's ψ/lab.



