Skip to content

v0.1.0 — a Drupal-shaped content store your models can write to

Choose a tag to compare

@nazt nazt released this 04 Sep 01:06
· 19 commits to main since this release

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.

Deploy to Cloudflare

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 digger-node web UI

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.

The untagged queue and the MCP call log

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.

Thai substring search

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.

The OAuth approval page

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.

Connecting claude.ai

What is in the box

  • 19 MCP tools over one stateless POST /mcp, echoing the client's protocolVersion
  • 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, tsc clean, verified live on workerd and on production

Scars, documented in place rather than smoothed over

  • aot: false is required on Workers. Elysia's AOT compiler builds handlers with new Function(); workerd forbids it. Every test passed; only wrangler dev caught it.
  • D1 returns BLOBs as number[], bun:sqlite as Uint8Array. 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 /mcp wide open. Removing as: "global" fails 7 tests; without that test it fails none.
  • A 200 wrapping isError: true never triggers an auth prompt. The gate must run at the HTTP layer, before the JSON-RPC handler.

Docs

MIT. Spun out of digger-oracle's ψ/lab.