trace-node
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@trace-nodefind nodes tagged with Engineering"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
trace-node
Fork note. trace-node is forked from Soul-Brews-Studio/digger-node @
ff5b5cd(seeNOTICEandpackage.jsonforkedFrom). This README is the inherited digger-node README with names updated; the code, thedigger-nodestrings inside it, and the 16 screenshots underdocs/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.
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.

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: |
vocabulary | A namespace for terms, with a policy — see below |
term | A label inside a vocabulary, nestable via |
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 statusSelecting 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 aGROUP BY … HAVING COUNT, not aWHERE. 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 derived — node_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 textMeasured 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.

Connect it
Claude Code
claude mcp add --transport http digger-node https://<your-worker>.workers.dev/mcpAnything 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 |
| RFC 9728 — what a client reads first, from the 401's |
| the bare variant, because clients ask for it |
| RFC 8414 — where |
| the same document again; a client that 404s twice stops looking |
| RFC 7591 dynamic client registration (JSON) |
| the approval page — the only human step |
| code + verifier → access token (form-urlencoded) |
| the browser's cookie session |
| 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:
issuernot 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. SetPUBLIC_URLonly if a proxy rewritesHost.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.Refusing an unknown
redirect_uriby redirecting to it. That is the open redirect the exact-match check exists to prevent. Failures stay on our own page.Auth checked inside a tool instead of at the HTTP layer. A 200 wrapping
isError: truenever triggers an auth prompt — the model just reads "please sign in" as text and carries on. The gate runs beforehandleMcpis 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 |
| The MCP endpoint (JSON-RPC, stateless — no sessions, no SSE) |
| The site: create form, taxonomy nav, tag cloud, search, live call log |
| Driver, auth shape, tool count, node count — never a secret |
| Nodes, or search |
| One node with its terms |
| Create (accepts |
| Create taxonomy from the browser |
| Tag an existing node |
| Taxonomy |
| The call log |
| Content types in use + whether the guard is on |
| Tool catalogue, corpus shape |
| Who holds OAuth access; revoke one |
| The OAuth flow |
| 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 |
| 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 |
| RFC 9728 | The same document at the bare path. See the note below — this one is a hedge, not a requirement. |
| RFC 8414 | "I issue tokens — here are my endpoints." Where |
| 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 workEverything 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
typeis 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 devRun
bun run test, not barebun test. The script is pinned tobun test ./testfor 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 turnedbun testinto a silent no-op printing only its version banner, whilebun test ./test/app.test.tsran 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 SQLiteMIT. 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.
This server cannot be installed
Maintenance
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
Shared, governed long-term memory for AI agents across tools and sessions via MCP and REST.
Create, browse, remix, collaborate on, and run durable AI workflow nodes from MCP hosts.
Manage SRG+ hubs, channels, content, assets, users, and workspaces from any MCP-aware AI agent.
Your org's AI agents, tasks, runs, search, and brain files as MCP tools and resources.
Related MCP Servers
- AlicenseAqualityCmaintenanceMCP server for Drupal sites via JSON:API, enabling CRUD operations on nodes, taxonomy terms, and users using natural language.8MIT
- FlicenseNot gradedqualityDmaintenanceEnables file system operations, web scraping, and AI-powered search through MCP tools for use by LLM agents.1-
- FlicenseNot gradedqualityDmaintenanceA universal connector hub that proxies Claude.ai tool calls to any CMS that runs an MCP server, enabling natural language interactions with Drupal, Joomla, WordPress, and more through a single endpoint.-
- AlicenseNot gradedqualityCmaintenanceEnables AI assistants to interact with Drupal sites through MCP tools, with automatic discovery, OAuth-based authentication, and scope validation.285MIT
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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