Skip to main content
Glama

trace-node

Fork note. trace-node is forked from Soul-Brews-Studio/digger-node @ ff5b5cd (see NOTICE and package.json forkedFrom). This README is the inherited digger-node README with names updated; the code, the digger-node strings inside it, and the 16 screenshots under docs/images/ are digger's and show digger's UI until they are regenerated at gate 2. The trace layer that gives this fork its name is described in ψ/lab/01-trace-node/PRD.md.

A Drupal-shaped content store — title, body, datetime, taxonomy — that a model talks to over MCP, running on one Cloudflare Worker with one D1 database.

Deploy to Cloudflare

One click provisions the D1 database, 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

Every screenshot in this repo is a real running instance — real corpus, real OAuth flow, real error strings. Take the tour.


The content model, in four nouns

Drupal got this part right in 2004 and most tools have been re-inventing it worse ever since:

Noun

What it is

node

One piece of content: title, body, created_at, plus a free-text type and a published flag

vocabulary

A namespace for terms, with a policy — see below

term

A label inside a vocabulary, nestable via parent_id, ordered by weight

node_terms

The join: a node wears any number of terms

Tags vs categories — the distinction worth copying

Drupal ships two kinds of vocabulary, and it is a policy rather than a display option:

kind: "tags"        free-tagging  → unknown terms are CREATED on demand
                                    many, specific, flat  (tags, topics)

kind: "categories"  controlled    → unknown terms are REFUSED
                                    few, broad, stable    (section, status)

The controlled kind is the guard that matters when a model does the tagging. Ask an LLM to file the same idea twice and you get mcp, MCP and model-context-protocol — three rows, one concept, and a taxonomy that has quietly stopped answering "everything about X". Nothing errors. With a controlled vocabulary it errors usefully instead:

"Enginering" is not a term in the controlled vocabulary "section". Available: Engineering, Field notes, Archive. Add it deliberately with term_create, or use a free-tagging vocabulary.

The menu is the taxonomy

Drupal keeps menus separate from taxonomy and bridges them with the Taxonomy Menu module. We kept the bridge and skipped the second system: terms.weight orders a vocabulary, so a controlled vocabulary rendered in weight order is the menu — with nothing to keep in step. The site renders categories as an ordered menu and tags as a cloud, which is the distinction made visible.

That is the whole schema. It is enough to be a blog, a wiki, a bug tracker, a reading list, or a field notebook — the difference between those is which type strings and which vocabularies you happen to use, not a migration.

Related MCP server: Sentinel Core Agent

Nineteen MCP tools

node_create  node_get  node_update  node_delete  node_list  node_search
node_tag     node_untag  node_embed  node_types
vocabulary_create  vocabulary_list  term_create  term_list  term_weight
call_log     call_stats            status

Selecting by tags — one, many, AND or OR

// at least one of these tags (default)
{ "terms": ["topics:mcp", "tags:bangkok"], "match": "any" }
// every one of them
{ "terms": ["topics:mcp", "tags:bangkok"], "match": "all" }
// by id instead of name
{ "term_ids": ["term_abc", "term_def"], "match": "all" }

Over REST: ?terms=topics:mcp,tags:bangkok&match=all.

The response echoes the match that actually ran, because "all" quietly behaving as "any" is a filter bug you only notice as a wrong answer. Two things this gets right that are easy to get wrong:

  • match: "all" is a GROUP BY … HAVING COUNT, not a WHERE. Written as a WHERE it returns nothing, because no single join row can equal two terms.

  • A tag that does not exist returns nothing, not everything. Resolving zero ids and then falling through to the unfiltered query would answer "nodes tagged X" with the entire corpus. There is a test for it.

Managing content types

Types are derivednode_types reports what is in use with counts, and a type exists because a node names it. There is no registry to maintain and nothing to keep in step.

That is the permissive default, and it has the same drift problem free tags do: a model will happily write note, Note and notes. So the lock is the taxonomy machinery you already have, not a second system:

vocabulary_create { "name": "type", "kind": "categories" }
term_create       { "vocabulary": "type", "name": "article" }

With a controlled vocabulary named type, node_create and node_update refuse anything not on the list — and say what is:

"aritcle" is not an allowed content type. Available: article, note. Add it with term_create{vocabulary:"type"}, or delete the controlled "type" vocabulary to allow free text.

node_types reports policy: "free" | "controlled" so the state is never a guess.

Three search modes, and why the default is the boring one

mode="text"      (default)  trigram index — matches inside Thai words, 7ms
mode="semantic"             bge-m3 via Workers AI — finds what you DESCRIBE
mode="hybrid"               RRF k=60 — opt-in, and measured worse than text

Measured on this fleet's own corpus (n=200, 124k blocks): text scored 0.765 MRR for known-item retrieval, semantic 0.099, and hybrid 0.437 — equal- weight fusion lets a confident-but-wrong neighbour list drag down a confident- and-right lexical one. So text is the default and hybrid is never automatic.

Semantic earns its place on the other question. Live on this deployment:

query

text

semantic

"why do we split words differently for other languages"

0 hits

"Why trigram, not unicode61" · 0.545

"การจัดหมวดหมู่เนื้อหา" (Thai)

"Categories vs tags" · 0.471

That second row is the reason the model is multilingual: a Thai query finding English content. MiniLM-class encoders are blind to Thai and would return noise.

Every response reports the mode that ran and, for semantic, coverage ("4/4 nodes embedded") — because a low score from an un-embedded row is a coverage fact, not a ranking result, and this fleet has misread that before.

Tagging takes "vocabulary:term" strings and creates whatever is missing, so a first call needs no setup:

{ "title": "Bangkok flood sensors", "body": "…", "terms": ["topics:iot", "tags:bangkok"] }

The call log is a first-class table

Every tool call is recorded with its arguments, outcome, result and duration — successes and failures both. call_log and call_stats expose it over MCP, the web page renders it live, and /api/calls serves it as JSON.

An MCP server you cannot watch is one you are trusting on faith: "the model said it saved that" is not evidence a row exists. Now you can check.

Screenshots

A visual tour of every screen — the site, the untagged queue, Thai trigram search, tag filtering, the lock screen and the OAuth consent page — is in docs/. For the connector flow specifically, see docs/connect-claude-ai.md.

The digger-node web UI

Connect it

Claude Code

claude mcp add --transport http digger-node https://<your-worker>.workers.dev/mcp

Anything else that speaks streamable HTTP MCP — point it at POST /mcp.

claude.ai — Settings → Connectors → Add custom connector, paste the /mcp URL. Set OWNER_PASSPHRASE first (below): claude.ai registers itself, sends you to an approval page, and you type the passphrase once.

curl, or anything that reads a config file — send Authorization: Bearer $API_TOKEN.

Auth is opt-in, and the server says which state it is in

With no secrets set the server is open: anyone who can reach the URL can read and write the whole corpus, and /health reports "auth": "none" so it is never a guess. That is the deliberate first-run state for a one-click deploy — a button that produces a Worker returning 401 to its own owner, with no screen on which to set a secret, is a broken install.

Turn it on with either secret, or both:

# OAuth for claude.ai, plus the web login. One passphrase is the whole account system.
wrangler secret put OWNER_PASSPHRASE

# A static bearer for curl, Claude Code, scripts, a desktop client.
wrangler secret put API_TOKEN

/health then names the doors rather than saying "on":

{ "auth": ["api-token", "oauth", "owner-session"] }

That specificity matters: "auth": true on a server where only API_TOKEN is set would read as "claude.ai can connect", which it could not.

The passphrase has a guessing budget

Every other secret here has real entropy — a 32-byte token, a PKCE verifier, a single-use code burned on the first failed exchange. The owner passphrase is the one a human chose, and /login and /authorize were willing to be asked about it without limit. On a *.workers.dev hostname, which is world-reachable the moment it exists, that is what turns a short passphrase from "weak" into "falls this afternoon". The crypto was never the weak link; the budget was.

Five wrong guesses per address per door, then backoff: 2m, 4m, 8m … capped at an hour, so a mistake is always recoverable by waiting rather than by redeploying. A correct passphrase clears the record, and /login and /authorize have separate budgets. The throttle gates the attempt, not the verdict — a locked-out caller holding the right passphrase still gets a 429, or the lockout would tell them the moment they hit it.

It is not a perimeter: it keys on CF-Connecting-IP, so many addresses means many budgets and a shared NAT gives many users one. It converts an unlimited online guessing attack into a limited one. A long passphrase is still the real defence; this buys the time to have chosen one.

Optional, and off is a legitimate answer — behind Cloudflare Access, on a private network, or with a 40-character passphrase it buys nothing and costs a D1 write per failed attempt:

wrangler secret put RATE_LIMIT   # or a var: "off" / "false" / "0" / "no"

On by default whenever auth is on, because the deployment that most needs a guessing budget is the one nobody configured. /health reports rate_limit: true | false | null (null = open server, nothing to protect). It fails open: if the store errors the attempt proceeds, because failing closed would let a transient database fault lock you out of your own corpus through the only door that could fix it.

Three keys, one gate

Key

Who it is for

Why nothing else works for them

oauth

claude.ai connectors

They cannot send a static header at all. OAuth is the only door.

api-token

curl, Claude Code, scripts, Tauri

They read a config file. The OAuth dance would be ceremony with no benefit.

owner-session

the web page

A browser has cookies and cannot hold a bearer token without script keeping it somewhere an XSS could read.

All three open the same corpus with the same rights. The distinction is how the caller proved it is the owner, not what it may then do.

What the OAuth actually is

OAuth 2.1: authorization code + PKCE S256 only (plain is refused at issue time, not merely unadvertised), Dynamic Client Registration because claude.ai registers itself, and the two discovery documents a client needs to find any of it. Codes live 10 minutes and are single-use; tokens live 30 days; there are no refresh tokens and no client secrets. Storage is three tables in the D1 database that is already bound — no KV namespace, because every added binding is one more thing the deploy button can fail to provision.

Endpoint

Purpose

GET /.well-known/oauth-protected-resource/mcp

RFC 9728 — what a client reads first, from the 401's WWW-Authenticate

GET /.well-known/oauth-protected-resource

the bare variant, because clients ask for it

GET /.well-known/oauth-authorization-server

RFC 8414 — where /authorize and /oauth/token live

GET /.well-known/openid-configuration

the same document again; a client that 404s twice stops looking

POST /oauth/register

RFC 7591 dynamic client registration (JSON)

GET/POST /authorize

the approval page — the only human step

POST /oauth/token

code + verifier → access token (form-urlencoded)

POST /login POST /logout

the browser's cookie session

GET /api/clients DELETE /api/clients/:id

who holds access, and how to take it back

The four ways this breaks silently

Every one of these produces "could not connect" with nothing useful in any log, and each is guarded by a test in test/auth.test.ts:

  1. issuer not byte-identical to the origin the client reached. A trailing slash or the wrong host fails every OAuth client while the static-token path keeps working — a deliberately confusing pair of symptoms. Set PUBLIC_URL only if a proxy rewrites Host.

  2. A 401 without the WWW-Authenticate: … resource_metadata=… pointer. The client never learns OAuth exists here. It must also be CORS-exposed, or a browser client cannot read the header it was sent.

  3. Refusing an unknown redirect_uri by redirecting to it. That is the open redirect the exact-match check exists to prevent. Failures stay on our own page.

  4. Auth checked inside a tool instead of at the HTTP layer. A 200 wrapping isError: true never triggers an auth prompt — the model just reads "please sign in" as text and carries on. The gate runs before handleMcp is called.

One more that is worth knowing but is not a bug: claude.ai caches discovery documents globally by URL for about five minutes. A metadata fix that appears not to have worked, thirty seconds after a redeploy, has probably worked.

Routes

Route

What

POST /mcp

The MCP endpoint (JSON-RPC, stateless — no sessions, no SSE)

GET /

The site: create form, taxonomy nav, tag cloud, search, live call log

GET /health

Driver, auth shape, tool count, node count — never a secret

GET /api/nodes ?q= ?type= ?terms= ?match= ?untagged=1

Nodes, or search

GET /api/nodes/:id

One node with its terms

POST /api/nodes

Create (accepts terms[])

POST /api/vocabularies POST /api/terms

Create taxonomy from the browser

POST /api/nodes/:id/tags

Tag an existing node

GET /api/terms /api/vocabularies

Taxonomy

GET /api/calls /api/calls/stats

The call log

GET /api/types

Content types in use + whether the guard is on

GET /api/tools /api/stats

Tool catalogue, corpus shape

GET /api/clients DELETE /api/clients/:id

Who holds OAuth access; revoke one

POST /oauth/register /oauth/token GET,POST /authorize

The OAuth flow

POST /login POST /logout

The browser's cookie session

Everything except /health, /, the discovery documents and the OAuth flow itself is behind the gate once a secret is set. The allow-list is one Set in src/auth-plugin.ts — a route added later is protected by default.

The four discovery documents, and why there are four

Always public, even with OAuth switched off — a client that fetches these on an open server learns the endpoints exist and then gets a 200 from /mcp without a token, which is the truth. Hiding them when unconfigured would make "is OAuth available here?" unanswerable.

They are not variants of one document. The first two answer questions asked by different roles, and this Worker happens to be both roles at once:

Route

Spec

Answers

GET /.well-known/oauth-protected-resource/mcp

RFC 9728

"I am guarded — here is who guards me." The first thing a client fetches, having been pointed at it by the 401's WWW-Authenticate header.

GET /.well-known/oauth-protected-resource

RFC 9728

The same document at the bare path. See the note below — this one is a hedge, not a requirement.

GET /.well-known/oauth-authorization-server

RFC 8414

"I issue tokens — here are my endpoints." Where /authorize, /oauth/token and /oauth/register are named, and where PKCE support is advertised.

GET /.well-known/openid-configuration

OpenID Connect Discovery 1.0

Byte-identical to the RFC 8414 document. This server is not an OpenID provider.

Why an OAuth server answers at an openid path. OpenID Connect shipped a discovery endpoint in 2014; OAuth did not standardise its own until RFC 8414 in 2018, which deliberately chose a different name so that an authorization server which is not an OpenID provider does not advertise itself as one. By then every client already spoke the OIDC path, so it never died. A compliant MCP client tries RFC 8414 first, falls back to the OIDC name, and stops looking if both 404 — so serving the same bytes twice is insurance against a discovery chain that dead-ends, and costs one route.

Why the bare oauth-protected-resource path is a hedge. RFC 9728 inserts the well-known segment between host and path: for a resource at https://host/mcp the correct metadata URL is https://host/.well-known/oauth-protected-resource/mcp. The bare form is correct only for a resource at the origin root — and the document served there names /mcp, which at that URL is slightly untrue. It is served because clients probe it anyway, and it is safe because RFC 9728 requires the client to check that resource matches what it asked for: a client wanting /mcp is helped, and one wanting the root correctly rejects it. If it never fires, it should go.

The web UI

Who tags: the model, not the person

A human supplies a title, a body and a type — the things a human knows. Nobody should be hand-typing topics:mcp, tags:bangkok; classification is what an agent is good at and what MCP is for.

So in the page, every tag is a read control: click one in the sidebar, or click one printed on a node, to filter by it. The only tag-shaped thing a human gets is the untagged queue — the list of content the model has not reached.

The loop, verified live on this deployment:

human   POST /api/nodes            → node with no tags
agent   node_list {untagged:true}  → 3 waiting
agent   node_tag  {id, terms:[…]}  → classified
human   clicks a tag               → finds the agent's work

Everything the browser writes goes through the same endpoints the MCP tools call — there is no browser-only path, so a node typed by a human and one written by a model are identical rows.

  • Create — title, body, and a type control that follows the policy (a dropdown when type is controlled, free text when it is not). It does not ask for tags.

  • Organization — vocabularies and terms are creatable from the page. A controlled vocabulary renders as an ordered menu (by weight); a free-tagging one renders as a tag cloud sized by usage.

  • Filter — every tag is a read control: click any of them (in the sidebar, or on a node itself) to filter, toggle any/all, and the selection lives in the URL (#/terms/id1,id2/all) so a view is shareable and survives reload.

  • Untagged — the queue of content nothing has classified yet, linked from the corpus panel and available as node_list{untagged:true} so an agent can find its own work.

  • Search — with the mode picker (text / semantic / hybrid), and the heading reports the mode that actually ran plus embedding coverage.

  • Errors are shown verbatim, which is why refusals are worth writing well: a controlled vocabulary answers with the terms that are allowed.

The tag cloud sizes by usage, on a log scale

/api/terms returns a usage count per term (a LEFT JOIN, so a term with no nodes still appears — hiding it would make a fresh vocabulary look broken). Sizes run 11px→20px on log scale: with counts of 1 and 40 a linear map makes every other tag identically tiny and the cloud stops carrying information, which is why tag clouds have always used log.

Versioning

CalVer: v{yy}.{m}.{d}v26.9.4 is 2026-09-04, not "version 26". The fleet this came from versions the same way.

The reason is honest rather than fashionable: this is a personal tool that ships whenever something is learned, so a semantic version number would be claiming a compatibility contract nobody is maintaining. A date says what it actually is — the state of the thing on the day it was cut. /health and the page footer report it, so a running deployment always names its own build.

Tags before v26.9.4 are semver (v0.1.0, v0.2.0); they are left alone rather than rewritten. CalVer sorts after them, so tooling that expects ordering keeps working.

Local

bun install
bun run test          # 110 tests, in-memory SQLite, no wrangler needed
bun run typecheck
bun run db:local      # apply migrations to the local D1
bun run dev           # wrangler dev

Run bun run test, not bare bun test. The script is pinned to bun test ./test for a reason. Bun's default test discovery walks the whole repo root, and if anything there resolves into a large or symlinked tree — a symlink to a notes vault, a sibling checkout — discovery can match zero files and still exit 0. Measured here: a ψ symlink at the root turned bun test into a silent no-op printing only its version banner, while bun test ./test/app.test.ts ran 63 tests from the same working directory. A green exit code with nothing run is worse than a red one, so the path is explicit and does not depend on what else is lying around.

Two things worth knowing

Search is trigram, on purpose. FTS5's default unicode61 tokenizer splits on spaces, and Thai does not use them — so a Thai sentence becomes one token and searching a word inside it returns nothing. This fleet has now measured that independently four times. Queries under 3 characters can't use a trigram index, so they fall back to a LIKE scan and the response says "mode": "like". A search tool that degrades silently is one that lies about its own recall.

The storage layer is a port, not an ORM. src/store/types.ts defines four operations; store/d1.ts and store/sqlite.ts implement them. The repository, the SQL and the MCP handlers never see D1 — which is why the test suite runs the identical code against in-memory SQLite, and why a Tauri desktop build can run it against a local file. What is deliberately not abstracted is SQL dialect: the statements are SQLite-flavoured, and moving to Postgres means a second statement file, not just a second adapter.

Layout

src/
  index.ts        Worker entry — the only file that knows about D1
  app.ts          Elysia routes, built from a Store
  mcp.ts          MCP JSON-RPC + the tool catalogue
  db.ts           Repository — no SQL text, no D1 types
  sql.ts          Every statement, as named constants
  utils.ts        Ids, slugs, clamps, FTS quoting, token crypto
  page.ts         The one HTML page
  auth-plugin.ts  The gate + OAuth + login, as one Elysia plugin
  auth.ts         One gate, three keys — which one opened it
  oauth.ts        OAuth 2.1 AS: DCR, PKCE, codes, tokens
  session.ts      The browser's signed cookie (no session table)
  screens.ts      The consent and lock screens — deliberately JS-free
  store/          The port and its adapters (d1, sqlite)
migrations/       0001 schema · 0002 embeddings · 0003 oauth
test/             The whole stack against in-memory SQLite

MIT. Part of digger-oracle's ψ/lab.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections. Dates show when Glama detected each change.

No tool schema history has been recorded yet.

Maintenance

ActivityMaintained
ResponsivenessUnresponsive

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nat-build-with-oracle/trace-node'

If you have feedback or need assistance with the MCP directory API, please join our Discord server