# Cloudflare OS: An LLM Operating System Built on One Account, One Deployment, One Grammar

slug: the-unified-loop · https://miscsubjects.com/a/the-unified-loop · tags: infrastructure, cloudflare, mcp, agents, architecture, canonical · updated 2026-08-06T09:16:18.828Z

# Cloudflare OS: An LLM Operating System Built on One Account, One Deployment, One Grammar

This article is the canonical description of the miscsubjects.com build. It is written for a reader who has never seen it before. It assumes nothing. It defines every term. It links every claim to a source. It is the article you read if you want to understand what this build is, why it exists, and what it does.

---

## The problem this solves

AI systems today are opaque. A model reads your data, makes a decision, and you cannot reconstruct what it saw or why it acted. If the answer is wrong, you cannot trace the error. If the answer is right, you cannot prove it.

The industry response is more tooling: retrieval layers, evaluation harnesses, orchestration frameworks, observability dashboards. Each tool adds a layer. Each layer adds a cost. Each cost adds a failure mode. The stack grows. The opacity remains.

This build takes the opposite approach. It removes the stack. It replaces it with one grammar.

---

## The grammar: one object, one address, one receipt

The grammar is OIP — the [Object Invocation Protocol](/a/oip). It is the only protocol in the build. It governs every object, every invocation, every receipt.

An OIP object states the work, the input, the authority, the invocation route, the runner, the proof requirement, the receipt, the replay path, and the repair path. A model operates OIP by resolving an object, reading the object contract, invoking the object route, and returning the receipt.

The OIP unit is the work object. The OIP proof is the receipt. The OIP loop is object, invoke, ledger, receipt, replay, repair.

Every article is an OIP object. Every claim is an OIP object. Every source is an OIP object. Every capability is an OIP object. Every event in the ledger is an OIP object. The operator's Mac is an OIP object. The Stripe charge is an OIP object. The text message is an OIP object.

One grammar. One address. One receipt.

---

## The loop: how the system runs

The system runs in a short, receipted loop. Six steps. No retrieval layer. No evaluation harness. No orchestration framework.

**Step 1: Resolve.** A model receives a plain-language request. It queries the object grammar: `?ask=<plain language>` or `?key=<KEY>`. The grammar returns the matching object.

**Step 2: Contract.** The object carries a machine-readable contract. The contract describes the inputs, the behavior, the authority required, and the proof that must return.

**Step 3: Scope.** The model checks the credential. `?explain=1&share=TOKEN` reports what the credential permits. No credential, no invocation.

**Step 4: Invoke.** The model invokes the object. The object runs. The runner is a Cloudflare Worker, a D1 query, an R2 fetch, a KV read, a Queue message, a Workflow step, a Pages function, or an AI Gateway call. The runner is one of six Cloudflare primitives.

**Step 5: Ledger.** The invocation is recorded in the ledger. The ledger is append-only. The ledger is signed. The ledger is immutable.

**Step 6: Receipt.** The receipt is returned. The receipt contains the object, the input, the output, the cost, the timestamp, the signature, and the replay path. The receipt is the proof.

---

## The infrastructure: Cloudflare as the LLM OS

The build runs on one Cloudflare account. One deployment. One database. One operator.

The Cloudflare primitives used:

- **Workers**: The compute layer. Every capability is a Worker. Every Worker is a function. Every function is a receipt.
- **D1**: The database. One SQLite database at the edge. Every article, every claim, every source, every capability, every event.
- **R2**: The object store. Every image, every file, every artifact. Immutable. Hash-chained.
- **KV**: The key-value store. Session state, rate limits, feature flags.
- **Pages**: The frontend. Static. Fast. Global.
- **AI Gateway**: The model router. Every model call is logged. Every call has a cost. Every cost is receipted.
- **Workflows**: The orchestration. Multi-step operations. Durable. Receipted.
- **Vectorize**: The vector database. Semantic search. Embeddings. Similarity.
- **Queues**: The message queue. Async processing. Receipted delivery.
- **Email Routing**: The email layer. Inbound and outbound. Receipted.

One account. One deployment. One database. One operator.

---

## The evidence graph: how belief is computed

The build does not trust models. It verifies them.

Every model examination of an object is stored as a signed record. The record contains the model, the timestamp, the claim, the conclusion, and the signature. The record is immutable.

The evidence graph is the set of all signed records for all objects. The graph is queryable. The graph is public. The graph is auditable.

Belief is computed from the graph. The current belief value of an object is the highest-value signed record by recency, trust-weight, and independence. The computation is deterministic. The computation is public. The computation is receipted.

---

## The sources: where the evidence lives

Every claim in the build carries a source. The sources are public. The sources are queryable.

The sources include Cloudflare documentation, Anthropic documentation, Reddit reports, and independent operator reports. Each source is attached to a specific claim. Each source is hash-chained. Each source is immutable.

The sources are the evidence. The evidence is public. The public can audit.

---

## The objections: what this cannot do

This build cannot prove that a model's conclusion is correct. It can prove that the model examined the object, that it produced a signed record, and that the record is immutable. It cannot prove that the conclusion is true.

This build cannot prevent a model from hallucinating. It can prove that the model read the governing document. It cannot prove that the model understood it.

This build cannot scale to billions of objects without compaction. The append-only ledger grows. The graph resolution slows. The working set expands. The tradeoff is documented. The tradeoff is public.

---

## The verdict

This build is a working prototype of a different way to organize AI systems. Every article, tool, skill, claim, and API is the same kind of invocable object — one address, one history, a receipt for every action.

The build is not a product. It is a protocol. The protocol is public. The protocol is queryable. The protocol is auditable.

The build is not a company. It is one operator. The operator is public. The operator is accountable. The operator is the build.

The build is not a stack. It is one grammar. The grammar is public. The grammar is minimal. The grammar is enough.

---

## The links

- The protocol: https://miscsubjects.com/a/oip-spec
- The system map: https://miscsubjects.com/api/articles/system-map
- The proof: https://miscsubjects.com/api/proof
- The ledger: https://miscsubjects.com/admin/ledger
- The read gate: https://miscsubjects.com/a/read-gate
- Proof of coverage: https://miscsubjects.com/a/proof-of-coverage
- The evidence graph: https://miscsubjects.com/a/object-ledger-evidence-graph-spec

Every link is live. Every link is queryable. Every link is a receipt.


## Sources

1. Model Context Protocol — Tools — https://modelcontextprotocol.io/docs/concepts/tools
2. Anthropic — Tool search tool — https://docs.claude.com/en/docs/agents-and-tools/tool-use/tool-search-tool
3. Cloudflare D1 — https://developers.cloudflare.com/d1/
4. Cloudflare Pages Functions — Routing — https://developers.cloudflare.com/pages/functions/routing/
5. Cloudflare Workers — Limits — https://developers.cloudflare.com/workers/platform/limits/
6. OpenAI — Assistants overview — https://platform.openai.com/docs/assistants/overview
7. LangChain — Introduction — https://python.langchain.com/docs/introduction/
8. CrewAI documentation — https://docs.crewai.com/
9. LlamaIndex documentation — https://docs.llamaindex.ai/en/stable/
10. Hacker News — zc2610, 2026-04-14 — https://news.ycombinator.com/item?id=47767067
11. Hacker News — ramesh31, 2025-06-06 — https://news.ycombinator.com/item?id=44197352
12. Hacker News — justvugg, 2025-12-23 — https://news.ycombinator.com/item?id=46370218
13. Hacker News — DarmokJalad1701, 2025-06-04 — https://news.ycombinator.com/item?id=44186682
14. Cloudflare Workers AI — Pricing — https://developers.cloudflare.com/workers-ai/platform/pricing/
15. Operator report: tool definitions alone eat 50K-100K tokens — https://x.com/dani_avila7/status/1993134510364389555
16. Seven MCP servers burned 67k tokens before a keystroke — https://x.com/aakashgupta/status/2011664388424454262
17. Load nothing until it is needed — https://x.com/dzhng/status/2029518820872945889
18. Fifty definitions and the agent struggles to pick — https://x.com/milvusio/status/2047014068888703391
19. Practical ceiling of about forty MCP tools in Cursor — https://www.reddit.com/r/mcp/comments/1jbiqex/max_mcp_tool_limits_hit_in_cursor/
20. Bloated prompt, unstable model — https://www.reddit.com/r/mcp/comments/1jimv3u/whatll_hapen_if_there_has_a_lots_of_tool_in_mcp/
21. Specifications take space and degrade every tool — https://www.reddit.com/r/ClaudeAI/comments/1ka3jao/why_is_claude_is_so_good_at_tool_calling/mpz8b2g/
22. opencode #35376: 40k-70k tokens of definitions loaded upfront — https://github.com/anomalyco/opencode/issues/35376
23. copilot-cli #4189: reported cost 20x the real deferred cost — https://github.com/github/copilot-cli/issues/4189
24. hermes-agent #67273: schemas are 83.1% of the token budget — https://github.com/NousResearch/hermes-agent/issues/67273
25. The token claim is an implementation detail of the harness — https://news.ycombinator.com/item?id=48331540
26. Progressive disclosure already solves it — https://news.ycombinator.com/item?id=47719249
27. Prompt caching turns it into a fixed penalty — https://news.ycombinator.com/item?id=47160970


---

# Cloudflare OS: agents as infrastructure

slug: cloudflare-os-xl-04-agents-as-infrastructure · https://miscsubjects.com/a/cloudflare-os-xl-04-agents-as-infrastructure · category: systems · tags: cloudflare, agents, durable-objects, mcp, infrastructure · updated 2026-08-06T03:28:34.726Z

*Part 4 of [Cloudflare OS XL](/a/cloudflare-os-xl), an inventory of the Cloudflare platform this build does not have installed.*

This build already runs agents. There is an `AgentDO` Durable Object class, an agent registry with rows carrying prompts and model assignments, an `agent_turns` table recording what each one did, memory rows, a spawn path and a governor. That is a hand-rolled agent runtime, and it works.

The Agents SDK is Cloudflare's version of the same thing, and the interesting question is not "should this build have agents" — it has them — but which parts of the hand-rolled runtime are load-bearing and which are re-implementations of something the platform now provides.

## The Agents SDK

The SDK creates stateful agents with persistent memory, real-time WebSocket connections and scheduled tasks. Each agent is a Durable Object: it owns SQLite storage, it can be addressed by name, it survives restarts, and it can schedule itself.

Four things it provides that the current arrangement does not:

**Per-agent scheduling.** An agent can call `this.schedule(delay, 'methodName', payload)` and be woken up later. Today, everything scheduled in this build goes through a shared cron trigger firing every minute, which then decides what is due. That single cron is a queue, a scheduler and a dispatcher in one, and every scheduled behaviour in the system is coupled to it. Per-agent alarms decouple them.

**State as a first-class field.** The SDK gives an agent a synchronised `state` object and a SQL interface over its own storage. The current build stores agent memory in shared D1 tables keyed by agent name — which works, and which also means an agent's memory is only as isolated as the query that reads it.

**WebSockets with hibernation.** An agent can hold a live connection to a client and hibernate while idle, paying nothing for the wait. Long-running conversations currently reconnect through HTTP on every turn.

**A defined turn loop.** The SDK's `onMessage`, `onRequest` and callable-RPC surface is the shape this build wrote by hand in `AgentDO`.

The honest verdict is not "replace the agent runtime". It is narrower: **adopt the scheduling and the SQLite-per-agent storage; keep the registry, the prompts, the governor and the turn ledger.** Those last four are where this build's actual thinking lives — a law-bound prompt, an adjudication panel, a hash-chained record of what each model did — and none of them are things the SDK provides or should.

**Verdict: adopt in part.** Scheduling and per-agent state: yes. The registry and governance layer: keep what exists.

## Remote MCP servers with OAuth

This build's tool surface is already an MCP server. It runs locally, over stdio, through a bridge on the owner's machine, and it is reachable by exactly the clients configured on that machine.

Cloudflare hosts remote MCP servers as Workers, with `workers-oauth-provider` handling the authorization flow. The server becomes a URL. Any MCP client — Claude, an inspector, another agent, a partner's tooling — can attach to it by signing in, and the OAuth layer decides what each caller can see.

Three consequences for this build specifically.

**The bridge stops being a single point of failure.** Same argument as Part 3: capability that lives on a laptop is offline when the laptop is.

**Scope becomes structural rather than conventional.** This build has one act-scoped token that can edit articles and call every tool, plus a separate admin key. That is a deliberate design and it is documented. But it is enforced by the token check inside each handler, not by the protocol. An OAuth-fronted MCP server can present a different tool list to a different principal, which is a stronger form of the same idea.

**The build becomes attachable.** Its whole premise is that work is an object other agents can lease and act on. A public, authenticated MCP endpoint is the most direct expression of that premise available.

**Verdict: install.** This is the most on-thesis item in the entire series.

## Hibernatable WebSockets

Worth separating from the SDK, because it applies to Durable Objects generally and this build already has three classes.

A Durable Object holding a WebSocket normally stays in memory for the life of the connection. With the hibernation API, the DO can be evicted while the socket stays open, and is revived when a message actually arrives. The cost of an idle connection goes to approximately nothing.

The build has an obvious use: a live view of what agents are doing. Right now, watching the build work means polling an endpoint or reading a ledger tail. A hibernatable socket makes a push feed cheap enough to leave open indefinitely.

**Verdict: later.** Real, cheap, and not urgent until there is a surface that wants to watch.

## What this part does not recommend

**Do not rewrite `AgentDO` onto the SDK wholesale.** The temptation with a well-designed framework is to adopt all of it, and the parts of this build's agent layer that look like re-implementation are mostly not. The governor, the adjudication panel, the law-bound prompts and the turn ledger encode decisions that took months of corrections to arrive at. A framework migration that quietly drops them would be a regression wearing the clothes of an upgrade.

The rule to apply: adopt the platform where the platform provides *mechanism* — scheduling, storage isolation, connection handling. Keep what encodes *judgment*.

## Verdicts

| Product | What it replaces here | Verdict |
| --- | --- | --- |
| Agents SDK — scheduling | One shared cron firing every minute for all scheduled behaviour | **install** |
| Agents SDK — per-agent SQLite | Agent memory in shared D1 tables keyed by name | **install** |
| Agents SDK — turn loop, registry | The existing governor, prompts and turn ledger | **keep what exists** |
| Remote MCP server + OAuth | A stdio MCP bridge on one laptop | **install** |
| Hibernatable WebSockets | Polling an endpoint to watch agents work | **later** |

Next: [Part 5 — media](/a/cloudflare-os-xl-05-media).


## Sources

1. Cloudflare Agents SDK documentation — https://developers.cloudflare.com/agents/
2. Workers for Platforms documentation — https://developers.cloudflare.com/cloudflare-for-platforms/workers-for-platforms/


---

# Deferred tool search against a catalogue in a database: same cost today, different scaling

slug: tool-search-vs-catalogue-as-data · https://miscsubjects.com/a/tool-search-vs-catalogue-as-data · tags: tooling, mcp, architecture, comparison, tool-search, context-cost, measurement · updated 2026-07-26T03:53:50.309Z

On a catalogue of 891 capabilities, the two designs cost the same. Deferring the schemas and searching them on demand: 14,109 input tokens on a measured turn. Keeping the catalogue in a database behind one HTTP endpoint and sending no catalogue at all: 14,071. A difference of 38 tokens, which is noise.

The number is not the finding. The finding is what sits inside each number:

- With deferred tool search, **catalogue size is a term in the cost equation** — in Claude Code, one line per capability name in every request — and the mechanism only exists if the host implements it.
- With the catalogue in a database, catalogue size is not a term at all, and the mechanism is an HTTP request, which every client already has.

That holds at 50 capabilities and at 5,000. The token count expires the next time either side ships a change; the structural statement does not.

**What would falsify it.** One thing, precisely: a host that defers with *no per-capability residue in context*. Anthropic's server-side variant is documented to be exactly that — "the API excludes deferred tools from the system-prompt prefix" — so on the Claude API directly, tool search is also flat in context and the claim above is false as stated. It holds for Claude Code's client-side implementation, which is the one measured here, and Anthropic's own Claude Code documentation says so: "Only tool names and server instructions load at session start." Two implementations of one feature, two different cost curves. Check which one you are running before you believe either number.

[[embed:source:s1]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Every term used here, defined once

| Term | What it means on this page |
| --- | --- |
| **Tool definition** | A name, a description and a JSON Schema for the arguments, sent to the model so it can call something. Definitions are input tokens and are re-sent on every turn. |
| **Turn** | One request to the model and its reply. The whole conversation, including all tool definitions, is re-sent each time. |
| **MCP** | Model Context Protocol. A standard for a server to publish tool definitions and for a client to fetch them and put them in the prompt. |
| **Deferred loading** | Sending a tool's definition in the request but keeping it out of the model's context until it is asked for. Set with `defer_loading: true` on the API, or `ENABLE_TOOL_SEARCH=true` in Claude Code. |
| **Tool search** | The search tool the model calls to pull a deferred definition into context. Two vendor variants: regex (`tool_search_tool_regex_20251119`) and BM25 (`tool_search_tool_bm25_20251119`). |
| **Catalogue-as-data** | One SQL row per capability, holding the call shape, the docs, the argument template and the name of the credential. The model gets no list; it resolves an intent to a key over HTTP. [The full row contract](/a/directory-row-contract). |
| **Round trip** | One network request and response. Distinct from a model turn: four round trips can happen inside one turn, or across four. |
| **Prompt cache** | The provider storing the unchanging front of your prompt so re-sending it is cheaper. Anything that changes early in the prompt invalidates everything after it. |

## What the client actually puts on the wire, both ways

Both figures below come from one machine, `claude-cli 2.1.165`, same prompt, same working directory, one MCP server attached, captured by a local server that logs the request body and answers with a canned reply. The script is at the end of this page.

**With `ENABLE_TOOL_SEARCH=true`** the request carries nine tool definitions totalling 38,116 bytes of JSON:

```text
Agent            4351 bytes
AskUserQuestion  4199
Bash             2661
Edit              964
Read             1636
Skill            1713
ToolSearch       1440
Workflow        20503
Write             639
                -----
                38116 bytes, 9 definitions
```

Everything else arrives as a bare list of names in a `system` role message, which opens: "The following deferred tools are now available via ToolSearch. Their schemas are NOT loaded — calling them directly will fail with InputValidationError."

**With `ENABLE_TOOL_SEARCH=false`**, same session, the same request carries 858 definitions and 522,746 bytes — 13.7 times the JSON, for the identical set of capabilities.

**With the catalogue behind HTTP** the client sends its nine built-in definitions and nothing else. No MCP server attached, no name list, and all 891 capabilities still reachable, because reaching them is a `GET` and a `POST` to one endpoint rather than a tool the model was handed.

The translator carrying these requests to a non-Anthropic model converts the Anthropic `tools` array into OpenAI `function` entries one for one — `toOpenAITools()`, `functions/api/aig/[[path]].js:213-231` — and sizes an incoming request at lines 448-456 with `Math.ceil(chars / 3.7)`. The capture server behind the byte counts is `tools/capture-gateway.mjs:1-55` in the public repository, logging `n_tools` and `tool_names` at lines 25-26.

[[embed:source:s25]]

## The ToolSearch definition itself costs 1,440 bytes, once

Measured, not estimated. It is 3.8% of the nine-definition payload and it does not grow with the catalogue:

```json
{ "name": "ToolSearch",
  "description": "Fetches full schema definitions for deferred tools so they can be called. …",
  "input_schema": { "type": "object",
    "properties": { "query": {"type":"string"}, "max_results": {"type":"number","default":5} },
    "required": ["query","max_results"] } }
```

The catalogue-as-data equivalent — the instruction telling a model to resolve before it invokes — is prose in the system prompt, not a definition. One HN commenter who hand-rolled the same gate before either vendor shipped one put his at "about 60 tokens". Both are small. Neither side wins this row.

[[embed:source:s26]]
[[embed:source:s19]]

## Dimension by dimension, with a verdict in every cell

| Dimension | Deferred tool search | Catalogue-as-data | Verdict |
| --- | --- | --- | --- |
| Input tokens per turn, 891 capabilities | 14,109 | 14,071 | **Tie.** 38 tokens apart. |
| How cost scales with catalogue size | Client-side: ~37 bytes of name per capability, every turn. Server-side: nothing in the prefix, but every full definition is uploaded on every request. | Nothing. The 892nd row changes no byte the model sees. | **Catalogue-as-data**, and only structurally — at 891 rows the difference is invisible. |
| Prompt-cache behaviour | Documented as preserved server-side: deferred tools are excluded from the cached prefix and expanded inline. Reported as broken by operators lazy-loading definitions themselves. | Nothing about the tool surface ever changes, so nothing invalidates. | **Catalogue-as-data**, narrowly. The vendor claim and the field reports are both quoted below and describe different implementations. |
| Host support required | Yes. A host implementing deferral and expanding `tool_reference` blocks, on a model that supports them: Sonnet 4.5, Haiku 4.5, Opus 4.5 and later. | None. An HTTP client. | **Catalogue-as-data.** Not close. |
| Discovery latency and round trips | Zero network round trips — the search is answered locally or inside the same API call — but one extra model turn before the work starts. | Four HTTP round trips, measured at 2.185 s total for resolve → contract → invoke → receipt. | **Tool search.** The four round trips are real seconds. |
| Accuracy of tool selection | Vendor states selection degrades past 30-50 tools and that search keeps it high. The counter-argument: this is retrieval, which the field abandoned for full definitions on accuracy grounds. | A ranked query you own, index and can test offline. Measured here: `?ask=what time is it` returned 12 candidates and recommended `NOW` — not `TIME_NOW`, which was also in the list. | **Unresolved.** Both are retrieval. Neither side has published a head-to-head. |
| Adding a new capability | Publish it from a server; clients pick it up on reconnect, which invalidates the cache when tools sit in the prefix. | One `INSERT`. Live immediately, no restart, no reconnect. | **Catalogue-as-data.** |
| Who can call it | A harness that implements deferral. | Any HTTP client, including a model with no tool-calling support at all. | **Catalogue-as-data.** |
| Argument validation | Real JSON Schema, enforced by the host, with strict mode composing on top of deferral. | Pipe-delimited string, optional `input_schema` on the row, validated by the endpoint after the call is made. | **Tool search.** Decisively. |
| Ecosystem | Thousands of published MCP servers work unchanged. | Each server must become rows, or be reached one `http` row per endpoint. | **Tool search.** Decisively. |
| Observability and receipts | Not part of the mechanism. Tool lists are client-side state. | Every invocation writes an addressable receipt with input and output hashes, replay and repair links. | **Catalogue-as-data.** |
| Failure mode | A deferred tool is never searched for, and the run completes as if it did not exist. Filed against two harnesses. | A key does not resolve and the step fails with a `did_you_mean` list. | **Catalogue-as-data**, if a loud failure is worth more to you than a quiet one. |
| Effort to adopt inside a Claude host | One environment variable. | Implement or adopt a four-step protocol. | **Tool search.** Not close. |

[[embed:source:s5]]
[[embed:source:s16]]

## What tool search has that a catalogue behind HTTP does not

**No service to run.** Deferral is a setting — no database, no endpoint, no uptime, no deployment.

**It works when your infrastructure does not.** A catalogue behind HTTP is a hard dependency: endpoint down, model has no capabilities. Deferred definitions travel inside the request.

**Typed arguments.** Definitions carry real JSON Schema and the host constrains the model's output to match it. A row taking `"arg1|arg2"` gets validation only after the call has been made.

**It is a standard other harnesses are adopting.** `defer_loading` is not Anthropic-only — the Codex bug report below is evidence it shipped there too. A protocol one build invented is not a standard, whatever its merits.

[[embed:source:s2]]
[[embed:source:s15]]

## Six ways deferred tool search is documented to break

Every row is a filed report or a vendor page, quoted.

| What goes wrong | Where it is documented | What it costs you |
| --- | --- | --- |
| First-party servers are exempt from deferral, with no opt-out | anthropics/claude-code#76372: "With tool search active (`ENABLE_TOOL_SEARCH` unset), third-party MCP tools correctly defer to names-only. But three Desktop built-in servers load complete schemas upfront every session" | ~3.9k tokens per session that no setting removes |
| Servers visible in the client are invisible to the search index | anthropics/claude-code#57033: "the `ToolSearch` deferred-tool discovery mechanism does NOT include them in its index. Any `ToolSearch` query that should match a claude.ai MCP tool returns zero results" | Connectors show as Connected and cannot be reached |
| The deferral threshold is computed from the wrong model | NousResearch/hermes-agent#57520: "For any session running a model *other than* the configured default … the gate is scaled to the wrong window." | A 98,304-token local model gets a threshold sized for a 256K cloud model |
| A run completes silently empty | openai/codex#24536: "`codex exec` can silently finish with no assistant message when an explicitly configured MCP tool is deferred behind `tool_search`." | The task looks done and nothing happened |
| Lazy loading invalidates the prompt cache | sophiabits on HN: "The main problem with this approach at the moment is it busts your prompt cache, because LLMs expect all tool definitions to be defined at the beginning of the context window." | Input tokens are the main cost driver, so the saving can invert |
| It does not fully solve the problem it is cited for | cheema33 on HN: "And no, the tool search function recently introduced by Anthropic does not completely solve this problem." | You still pay for servers you never invoke on that turn |

[[embed:source:s8]]
[[embed:source:s9]]
[[embed:source:s10]]
[[embed:source:s11]]
[[embed:source:s21]]
[[embed:source:s20]]

And the objection that is not a bug, which is the strongest thing said against the whole direction:

> Seems like we traded scalability for accuracy, then accuracy for scalability… but I guess maybe we've come out on top because whatever they are using for tool search is better than RAG?

That is morelandjs on Hacker News, 2025-11-24, under Anthropic's own advanced-tool-use announcement. The field moved to putting every definition in context *because retrieval was less accurate*. Tool search walks that back and asks you to trust a BM25 or regex index instead. Anthropic's position is the opposite — selection "degrades once you exceed 30–50 available tools", and search keeps it high. Both statements are published, neither is backed by a head-to-head benchmark either side has released, and a catalogue-as-data resolver is retrieval too, so it inherits the same objection. This page does not resolve it and will not pretend to.

[[embed:source:s17]]

## Five ways catalogue-as-data breaks

**No host-side type validation.** The model sends a pipe-delimited string. Nothing constrains its output to a schema before the call leaves. Malformed arguments are caught by the endpoint, after the request, and surface as a failed invocation rather than a rejected tool call.

**No standard.** One build's protocol. No ecosystem, no published servers, no client that already speaks it.

**It needs a live service.** The catalogue's uptime is now the agent's capability uptime, which deferred definitions never are.

**Four round trips before any work happens.** Measured on 2026-07-25 against production:

```text
resolve   GET  /api/dispatch?ask=what%20time%20is%20it     200   0.619 s   12,332 B
contract  GET  /api/dispatch?key=TIME_NOW&format=markdown  200   0.215 s    4,421 B
invoke    POST /api/dispatch {"key":"TIME_NOW","body":""}  200   1.047 s   15,607 B
receipt   GET  /api/dispatch?confirm=inv_3wt7dcbp2c        200   0.304 s   15,049 B
                                                          -----------------------
                                                           2.185 s   47,409 B
```

Deferred tool search pays none of that in network time. It pays one extra model turn instead, which on a slow model is worse and on a fast one is better.

**The resolve step is a search that can miss.** In the run above, `?ask=what time is it` returned 12 matches and recommended `NOW`. `TIME_NOW` was in the list and was not the recommendation. Both work. But that is the accuracy objection landing on this side of the table, in a measurement taken for this page, and it is the same failure class the critics aim at tool search.

[[embed:source:s27]]

## The arithmetic at 50, 891 and 5,000 capabilities

Two measured constants do the work, both from the captures above:

- **571 bytes** per tool definition. Derived: (522,746 − 38,116) bytes ÷ (858 − 9) definitions.
- **36.7 bytes** per deferred tool name. Derived: 30,551 bytes of names ÷ 833 MCP tool names.

Bytes are exact. To convert to tokens: (149,187 − 14,109) input tokens across (856 − 9) definitions gives **159 tokens per definition**, and dividing the byte figure by the token figure gives **3.59 bytes per token** for this JSON. Independently, the translator in this build estimates at `chars / 3.7`. The two agree closely enough to use for orders of magnitude and not closely enough to quote to four figures.

| Catalogue size | Every definition in context | Tool search, client-side (names only) | Catalogue-as-data |
| --- | --- | --- | --- |
| 50 | 50 × 571 = 28,550 B ≈ 7,950 tokens | 50 × 36.7 = 1,835 B ≈ 511 tokens | 0 B, 0 tokens |
| 891 | 891 × 571 = 508,761 B ≈ 141,700 tokens | 891 × 36.7 = 32,700 B ≈ 9,110 tokens | 0 B, 0 tokens |
| 5,000 | 5,000 × 571 = 2,855,000 B ≈ 795,300 tokens | 5,000 × 36.7 = 183,500 B ≈ 51,100 tokens | 0 B, 0 tokens |

At **50 capabilities** all three are rounding errors against a 200,000-token window. Anthropic says as much: standard tool calling "is a better fit when you have fewer than 10 tools, every tool is used in every request, or your tool definitions are small". Do not build a catalogue for 50 rows.

At **891**, the measured case, column one is a bill you notice and the other two are small. That is why the two measured turns landed 38 tokens apart. The 9,110 tokens of names in column two are real, hiding inside a number that also holds the conversation.

At **5,000**, column one does not fit in a 200,000-token window at all, column two costs 51,100 tokens per turn in names before anything is retrieved, and column three still costs nothing. That is where the structural difference becomes a number instead of an argument — and where the server-side variant, which keeps names out of the prefix, would collapse column two to roughly zero, subject to its documented ceiling of 10,000 deferred tools per request.

Add to column two, in every row, the definitions actually retrieved: 159 tokens each, up to five per search by default.

[[embed:source:s14]]
[[embed:source:s24]]

## A non-Claude model drove the deferred loop, against two documentation pages

Two vendor pages say this configuration does not work.

Anthropic's Claude Code prompt-caching page, on when tool definitions land in the cached prefix instead of being deferred: this happens "when tool search is unavailable or disabled … such as on Google Cloud's Agent Platform or with a custom `ANTHROPIC_BASE_URL` gateway". The Claude Code MCP page is more explicit about the mechanism: tool search "is also disabled when `ANTHROPIC_BASE_URL` points to a non-first-party host, since most proxies don't forward `tool_reference` blocks."

Moonshot's Claude Code guide, in its environment-variable table for `ENABLE_TOOL_SEARCH`: "The Kimi endpoint does not support this feature yet; it must be set to `false`, otherwise tool calls misbehave."

**Measured on 2026-07-25**, `claude-cli 2.1.165`, `ANTHROPIC_BASE_URL` pointed at a self-hosted translator in front of Cloudflare AI Gateway, model `@cf/moonshotai/kimi-k2.7-code`, `ENABLE_TOOL_SEARCH=true`: Kimi K2.7 Code was asked to use an MCP tool it had never been shown, named `TIME_NOW`. It called `ToolSearch`, received the schema, invoked the tool, and returned the payload:

```json
{"now":"2026-07-25T18:35:56-07:00","today":"2026-07-25","time":"18:35:56",
 "zone":"America/Los_Angeles","iso":"2026-07-25T18:35:56-07:00"}
```

The same session measured 14,109 input tokens against 149,187 with the setting off.

The likeliest reconciliation, stated as an inference and not a fact: Claude Code's tool search is client-side — the client decides which definitions to send and answers `ToolSearch` itself, so it asks nothing of the endpoint. The vendor pages describe the *server-side* variant, which does require the endpoint to expand `tool_reference` blocks. Anthropic's documentation confirms both exist: "Tool search runs as a server-side tool, but you can also implement your own client-side tool search."

Do not settle this by trusting any of the three statements. Point `ANTHROPIC_BASE_URL` at the capture server below and read your own request bodies.

[[embed:source:s3]]
[[embed:source:s4]]
[[embed:source:s28]]

## Given your situation, pick this

| If this is you | Pick | Because |
| --- | --- | --- |
| Under 50 tools, all used most turns | Neither | Definitions in context are cheaper than any machinery around them, and Anthropic says so in its own "when to use" list. |
| Inside Claude Code, MCP servers you did not write, a bill you noticed | Tool search | One environment variable, 10.6× fewer input tokens on the case measured here, nothing to run. |
| Several different models must reach the same capabilities, some without tool calling | Catalogue-as-data | Deferral needs a supporting host and a supporting model. HTTP needs neither. |
| Thousands of capabilities and a fixed context budget | Catalogue-as-data, or the server-side API variant | Client-side deferral still carries a name per capability per turn; the other two carry nothing. |
| Every call must produce an auditable receipt | Catalogue-as-data | Receipts are not part of the deferral mechanism at any layer. |
| Typed arguments matter more than portability | Tool search | Real JSON Schema, host-enforced, composing with strict mode. |
| You are on a proxy or a non-Anthropic model | Measure before choosing | Two vendor pages say it will not work; one measurement says it does. Yours is the only one that decides. |
| You want both | Both | The same table here is projected as a per-row MCP tool list for hosts that want tools, and as the protocol for everything else: [MCP as a projection, not a home](/a/mcp-as-a-projection). |

[[embed:source:s22]]
[[embed:source:s23]]
[[embed:source:s13]]
[[embed:source:s12]]

## Reproduce every number on this page

**The wire captures.** Save this as `tooldump.mjs`. It answers the Anthropic Messages API with a canned reply and logs the byte size of every tool definition the client sends.

```javascript
import http from 'node:http';
import fs from 'node:fs';
const OUT = '/tmp/tooldump.jsonl';
http.createServer((req, res) => {
  let raw = '';
  req.on('data', c => (raw += c));
  req.on('end', () => {
    let b = null; try { b = JSON.parse(raw); } catch {}
    if (b && Array.isArray(b.tools)) {
      fs.appendFileSync(OUT, JSON.stringify({
        model: b.model, n_tools: b.tools.length,
        tools_bytes: JSON.stringify(b.tools).length,
        per_tool: b.tools.map(t => ({ name: t.name, bytes: JSON.stringify(t).length })),
      }) + '\n');
    }
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ id:'msg_1', type:'message', role:'assistant',
      model: b?.model || 'local', content:[{type:'text',text:'ok'}],
      stop_reason:'end_turn', usage:{input_tokens:10,output_tokens:2} }));
  });
}).listen(8788, () => console.log('tooldump on :8788'));
```

Run it, then run the client once each way:

```bash
node tooldump.mjs &
for v in true false; do
  ANTHROPIC_BASE_URL=http://localhost:8788 ANTHROPIC_AUTH_TOKEN=x \
  ANTHROPIC_MODEL=local ENABLE_TOOL_SEARCH=$v claude -p "say ok" >/dev/null
done
python3 -c "
import json
for l in open('/tmp/tooldump.jsonl'):
    r = json.loads(l)
    print(r['n_tools'], r['tools_bytes'])"
```

Expected output on this machine, `claude-cli 2.1.165`, one MCP server attached:

```text
9 38116
858 522746
```

The name-list figure comes from the same file: filter `per_tool` to names beginning `mcp__` and sum their lengths. Expected `833` names and `30551` bytes.

**The four-step timing.** Every URL is public; only the invoke needs a credential:

```bash
curl -s -o /dev/null -w "%{http_code} %{time_total}s %{size_download}B\n" \
  "https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it"
curl -s -o /dev/null -w "%{http_code} %{time_total}s %{size_download}B\n" \
  "https://miscsubjects.com/api/dispatch?key=TIME_NOW&format=markdown"
```

The receipt for the invocation timed above is public and keyless: [`inv_3wt7dcbp2c`](https://miscsubjects.com/receipt/inv_3wt7dcbp2c).

[[embed:source:s7]]
[[embed:source:s18]]

**The gateway rows.** The 149,187 / 14,109 / 14,071 figures are Cloudflare AI Gateway log rows for `@cf/moonshotai/kimi-k2.7-code` on 2026-07-25; the method for producing them is [Why MCP tool schemas are most of your bill](/a/mcp-tool-search-cost). The design on the other side of this comparison is [Tooling as data](/a/tooling-as-data).

One caveat applying to every dollar figure and to none of the token counts: Cloudflare labels gateway cost an estimation, and one of those rows does not multiply out against the published per-million rate. The argument here is built on token counts for that reason.


## Sources

1. Tool search tool — Claude API documentation — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
2. Claude Code — Scale with MCP tool search — https://code.claude.com/docs/en/mcp
3. Claude Code — what invalidates the prompt cache — https://code.claude.com/docs/en/prompt-caching
4. Moonshot — Use Kimi with Claude Code — https://platform.kimi.ai/docs/guide/claude-code-kimi
5. Model Context Protocol specification — Tools — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
6. Tool use with prompt caching — defer_loading and cache preservation — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-use-with-prompt-caching
7. claude-code-cloudflare-gateway — the translator and the capture harness — https://github.com/redacted/claude-code-cloudflare-gateway
8. Desktop built-in MCP tool schemas load non-deferred with no opt-out — https://github.com/anthropics/claude-code/issues/76372
9. ToolSearch does not index tools from claude.ai-hosted MCP servers — https://github.com/anthropics/claude-code/issues/57033
10. codex exec can silently complete empty when configured MCP tools are deferred — https://github.com/openai/codex/issues/24536
11. tool_search auto-gate computes its threshold from the wrong model — https://github.com/NousResearch/hermes-agent/issues/57520
12. Dynamic MCP tool loading to reduce context window consumption — https://github.com/github/app/issues/1398
13. Context bloat: 16.9k tokens on MCP tool definitions loaded into the main agent — https://github.com/yonatangross/orchestkit/issues/885
14. Apideck CLI — an AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
15. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48332411
16. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48332962
17. Comment on: Claude Advanced Tool Use — https://news.ycombinator.com/item?id=46039648
18. Comment on: I still prefer MCP over skills — https://news.ycombinator.com/item?id=47719499
19. Comment on: I still prefer MCP over skills — https://news.ycombinator.com/item?id=47719249
20. Comment on: Chrome DevTools MCP — https://news.ycombinator.com/item?id=47392361
21. Comment on: When does MCP make sense vs CLI? — https://news.ycombinator.com/item?id=47209810
22. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48336021
23. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381322
24. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48331540
25. First-party wire capture: 9 definitions / 38,116 bytes against 858 / 522,746 bytes — https://github.com/redacted/claude-code-cloudflare-gateway
26. First-party measurement: the ToolSearch definition is 1,440 bytes, and 833 deferred names are 30,551 — https://github.com/redacted/claude-code-cloudflare-gateway
27. First-party timing: four round trips, 2.185 s, 47,409 bytes, with the public receipt — https://miscsubjects.com/receipt/inv_3wt7dcbp2c
28. Gateway log rows: 149,187 / 14,109 / 14,071 input tokens on the same catalogue — https://miscsubjects.com/api/articles/mcp-tool-search-cost


---

# MCP is one view of a capability table, and the view you pick costs 6.25x

slug: mcp-as-a-projection · https://miscsubjects.com/a/mcp-as-a-projection · tags: tooling, mcp, architecture, json-rpc, tool-cost, protocol · updated 2026-07-26T03:53:21.653Z

The Model Context Protocol (MCP) is a way of *describing* a set of capabilities to a model. It is not the place those capabilities live, and it is not the thing that runs them. On this site the capabilities live in one database table — one row per capability — and MCP is one of three ways that table is shown to a model.

**Scope note:** this page measures one thing well — what it costs to show *this* catalogue to a model three different ways. It is not a claim that the catalogue is the architecture. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) breaks the same table down by runner and category and shows MCP is 8 of the 891 rows measured here, not the subject the table exists to serve.

A **projection** here means exactly one thing: a view generated from a table, holding nothing of its own. Change the table and every view changes in the same instant. Delete a view and nothing is lost. Below, the plainer word **surface** is used wherever it reads more clearly; they mean the same thing.

The three surfaces reach the same capability catalogue. The recorded benchmark puts 6.25× between the cheapest and the dearest turn.

[[embed:source:m1]]

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## MCP at the wire is JSON-RPC 2.0, one of two transports, and a handful of method names

A reader who has never opened the specification can hold the whole thing in one paragraph. A client and a server exchange JSON-RPC 2.0 messages. JSON-RPC is a remote-procedure-call format: a JSON object carrying `jsonrpc`, `method`, `params` and `id`, answered by an object carrying the same `id` and either `result` or `error`. The specification is blunt about this: *"MCP uses JSON-RPC to encode messages. JSON-RPC messages **MUST** be UTF-8 encoded."*

Those messages travel over one of two transports. Standard input and output, for a server running as a local subprocess. Or HTTP POST, for a server running somewhere else — the specification calls the second one Streamable HTTP and requires that *"Every JSON-RPC message sent from the client **MUST** be a new HTTP POST request to the MCP endpoint."* It also states a preference: *"Clients **SHOULD** support stdio whenever possible."*

The method names that matter for a capability catalogue are four:

| Method | Direction | What it carries | What this site's server returns |
| --- | --- | --- | --- |
| `initialize` | client → server | protocol version, client capabilities | `protocolVersion: "2025-06-18"`, `capabilities: {"tools":{"listChanged":false}}`, `serverInfo` |
| `tools/list` | client → server | nothing, or a pagination cursor | an array of `{name, description, inputSchema}` — 831 of them, measured below |
| `tools/call` | client → server | `params.name`, `params.arguments` | `{content:[{type:"text",text:"…"}], isError:false}` |
| `notifications/tools/list_changed` | server → client | nothing | never sent — this server declares `listChanged: false` |

A tool definition is three fields: a name, a description in prose, and a JSON Schema for the arguments. That triple is what lands in the model's context window. Everything expensive about MCP follows from the size of that triple multiplied by the number of tools.

### What MCP is not

- **Not a wire protocol.** The layering claim gets used to wave away responsibility, and it is wrong. JSON-RPC is the wire protocol; MCP sits above it.
- **Not a place capabilities live.** A server holds handlers. Nothing in the specification says where the list of capabilities is stored, or that it must be a hand-written list at all.
- **Not a rule about context loading.** The tools specification says implementations *"are free to expose tools through any interface pattern that suits their needs—the protocol itself does not mandate any specific user interaction model."* Every token figure quoted below is a property of a client, not of the protocol.
- **Not a replacement for an API.** It is a second face on one. The endpoint stays; MCP is a decorated index in front of it.

## One recorded 891-row catalogue cost 6.25× more through its largest surface

The table holds 891 rows — 876 objects answered the live registry today, and the drift is explained under the measurements. Each row is a full capability contract: key, runner, target, documentation, input schema, authority flags. [What a directory row is](/a/directory-row-contract) covers the row itself; [891 tools, zero tool schemas](/a/tooling-as-data) covers why a catalogue is better held as data than as code.

| Surface | Tool definitions in the model's context | What the model can reach | What the client must support | Measured input tokens per turn | Measured cost per turn | What breaks |
| --- | --- | --- | --- | --- | --- | --- |
| **Protocol only** — `POST /api/dispatch` | 0. The 9 definitions present are the client's own built-ins | all 891 rows | an HTTP client. No MCP, no tool calling at all | 14,071 | $0.00456265 | the model must be told four endpoints once; no client UI enumerates the catalogue |
| **Small surface** — `workers/mcp-server` | 7 tools, whatever the catalogue size | all 891 rows, through the `dispatch` tool | an MCP client | not measured | not measured | a client that lists tools shows seven generic entries; discovery becomes a call, not context |
| **Per-row MCP** — `POST /api/mcp` | 831 measured today, 856 recorded | 831 rows, each a named tool | an MCP client | 149,187 | $0.02852109 | prompt cost, tool-selection accuracy, and strict-schema clients rejecting the whole list |
| **Per-row MCP, tool search on** | 9 plus a `ToolSearch` tool | all 891, loaded on demand | a client with deferred tool loading | 14,109 | $0.00443075 | the deferred index does not cover every server — two filed bugs below |

The arithmetic between the first and third rows: 149,187 − 14,071 = **135,116 input tokens burned per turn** holding definitions the turn mostly does not use. 149,187 ÷ 14,071 = **10.6× the input tokens**. $0.02852109 ÷ $0.00456265 = **6.25× the money**. Across a thousand turns that gap is **$23.96**. The token ratio and the money ratio differ because output tokens are in both bills and the cache behaves differently; both are the recorded gateway figures, not a rate derived from one of them.

[[embed:source:m2]]

## Each surface is a file, and the file states its own tool count

**Per-row MCP** is `functions/api/mcp.js`, 189 lines. Lines 118–137 are the entire projection:

```js
const r = await env.DB.prepare(
  'SELECT key, type, category, content, input_schema FROM directory ' +
  'WHERE IFNULL(enabled,1)=1 AND IFNULL(planner_visible,1)=1 ' +
  'ORDER BY IFNULL(planner_rank,100), key'
).all();
```

One row becomes one tool: `name` is the row key, `description` is the first documentation line plus `[type · category]`, `inputSchema` is the row's schema after normalisation. Line 162 is the whole `tools/list` handler. Lines 80–116 exist for one reason worth stating plainly: Moonshot and Kimi clients validate every schema strictly and reject the **entire** tool list on the first violation, so one legacy row with a `required` name missing from `properties` would blank the catalogue for that client. Claude Code tolerates both, which is how the bad schemas accumulated unnoticed.

**Small surface** is `workers/mcp-server/src/index.ts`, 138 lines, a Cloudflare `McpAgent` on a Durable Object. It registers seven tools, lines 19–105:

```
$ grep -A1 'this.server.tool($' workers/mcp-server/src/index.ts | grep '"'
      "d1_query",
      "kv_get",
      "kv_put",
      "list_directory",
      "dispatch",
      "oip_registry",
      "oip_invocations",
```

Seven, not six. An earlier record of this surface counted six by folding `kv_get` and `kv_put` into one line and omitting `d1_query`. The file is the authority and the file says seven. The invariant is the point either way: **seven tools whether the table holds 9 rows or 9,000**, because `dispatch` takes `{key, body}` and the key space is the table. All seven parameter descriptions together come to 429 characters of source.

**Protocol only** is `functions/api/dispatch.js`. No tool definitions are published anywhere. A model that can make an HTTP request resolves an intent, reads a contract, invokes it, and reads a receipt — four calls, set out in [Resolve, read, invoke, receipt](/a/dispatch-four-step-loop).

## Measured today: 831 definitions occupied 434,636 bytes on the wire

Two first-party measurements, both rerunnable. `MCP_TOKEN` is the server's bearer token; `/api/mcp` accepts it in `Authorization` or in `x-mcp-token`.

**Measurement 1 — count the per-row surface.**

```bash
curl -s -X POST https://miscsubjects.com/api/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -o tl.json \
  -w "http=%{http_code} bytes=%{size_download} time=%{time_total}s\n"

python3 -c "import json;t=json.load(open('tl.json'))['result']['tools'];print(len(t), len(json.dumps(t)))"
```

Three readings on 2026-07-26:

| Reading (UTC) | Tools | JSON bytes | What was counted | Wall time |
| --- | --- | --- | --- | --- |
| 04:37:06Z | 833 | 452,157 | Python re-serialization with spaces | not recorded |
| 04:39:08Z | 831 | 451,197 | Python re-serialization with spaces | 0.60 s |
| 05:03:04Z | 831 | 434,636 | exact HTTP response body | 0.267 s |

The HTTP body contains 434,592 bytes of compact tool definitions plus the 44-byte JSON-RPC wrapper. Python's default `json.dumps` adds spaces after separators, which is why the command above reports 451,197 for the same 831 definitions. Both numbers are reproducible; **434,636 is the network payload**.

Where the 451,197-character Python serialization goes, computed from the same file:

| Component of the tool list | Characters | Share |
| --- | --- | --- |
| Descriptions — first doc line plus `[type · category]` | 200,757 | 44.5% |
| Input schemas, after normalisation | 189,354 | 42.0% |
| Tool names | 13,862 | 3.1% |
| JSON structure and quoting | 47,224 | 10.5% |

Compact average per tool: **523 bytes**. Largest single definition in the spaced Python serialization: `CF_OBSERVABILITY_QUERY_WORKER_OBSERVABILITY` at 6,511 bytes. By runner type the 831 split into 462 `fn`, 301 `http`, 50 `flow`, 18 `agent`.

Three counts for the same server now exist on this page — 856 recorded, 833 at 04:37, 831 at 04:39 — and each is correct for its moment. Rows get added, disabled (`enabled = 0`) and hidden (`planner_visible = 0`) while the site runs, and only enabled plus planner-visible rows are projected. A tool count taken from an MCP server is a reading, not a constant. The 891 total and the 856 projection were taken on 2026-07-25.

**Measurement 2 — show that zero definitions still reaches everything.**

```bash
curl -s "https://miscsubjects.com/api/dispatch?registry=1" -o reg.json \
  -w "http=%{http_code} bytes=%{size_download} time=%{time_total}s\n"
python3 -c "import json;print(json.load(open('reg.json'))['count'])"

curl -s "https://miscsubjects.com/api/dispatch?ask=send%20an%20email" | head -c 400
```

Result at 05:03:04Z: `http=200 bytes=1606794 time=0.397555s`, and `876` objects. The resolver call returned `{"protocol":"OIP","version":"1.2.0","kind":"ask","question":"send an email","count":12,"best":{"key":"EMAIL_SEND", …}}` in 14,481 bytes — twelve candidate capabilities ranked, one recommended, none of it resident in a prompt. Nothing was registered with a model. No tool definition was loaded. All 876 objects are invokable by key.

[[embed:source:m3]]

## The case for shipping an MCP surface, at its strongest

The deflationary reading — MCP is a REST API with extra steps — is usually stated by people who then keep using it. **CharlieDigital**, whose team built one, put the deflation and the recommendation in a single sentence: *"MCP is effectively \"just another HTTP REST API\"; OAuth and everything. The key parts of the protocol is the communication shape and sequence with the client, which most SDKs abstract for you"*. Deploying one is no harder than deploying the API underneath it, because the SDK writes the transport.

**brookst** gave the sharpest rebuttal of the just-HTTP framing: *"It's like saying APIs are dead because you can just use HTTP. They're not the same thing, though of course you can hand-roll the higher layer in the lower one. It's just more work, less standard, less valuable."* A decorated index of features beats a bare endpoint list, and the decoration is the product.

**menix** argues the contract itself is the value rather than a nicety. Input and output schemas let a code-writing agent plan one precise program instead of a print-and-inspect loop, and *"Tool results from programmatic calls are not added to Claude's context window, only the final code output is. They report up to 98.7% token savings in some workflows."*

**827a** supplies the dissent every measurement below has to survive: *"The idea that MCP tool definitions take up a certain number of tokens is laughable. That's an implementation detail of the agent harness."* This is correct as written, and the specification agrees — nothing in MCP says a client must paste all definitions into a prompt. The measurements are of clients. They still decide the bill, because those are the clients that exist.

There is an audience argument too. **oortcrate_1** prefers a bash wrapper personally and still grants the point: MCP serves non-technical teammates who want a connection that works without filing a pull request.

## The case against, with the numbers the complainants actually measured

| Who | What they measured | Verdict |
| --- | --- | --- |
| moltar | *"Right now loading GitHub MCP takes something like 50k tokens."* | reduces MCP to "an API with docs"; wants progressive reveal |
| gertjandewilde (Apideck) | *"tool definitions alone burned 50,000+ tokens before the agent touched a single user message"* | replaced the server with a CLI at ~80 tokens; cites a 75-run comparison at 4–32× overhead |
| yonatangross | *"The main agent context burns 16.9k tokens (8.5% of 200k) loading 68 MCP tool schemas at session start. Most are never called by the main agent"* | publishes a per-server table; scoping and tool search were available and unused |
| abdlkrim-jribi | *"The agent injects ALL 47 tool schemas on every single request, consuming ~13,341 tokens before the user message is even sent."* | audits his own agent; publishes the ~4,168 / ~4,515 / ~751 / ~3,906 split |
| mjlee | *"With a handful of third party MCPs I've seen tens of thousands of tokens used before I've started anything."* | finds MCP beneficial anyway |
| 0xbadcafebee | *"Say each tool is 150 tokens, that's 150 * 50, or 7500 tokens, dumped into the beginning of every session."* | the balanced account — shell one-liners are more non-deterministic, so re-runs pollute context too; uses both |
| locknitpicker | *"Skills effectively turned MCPs obsolete in the vast majority of MCP applications."* | a progressive-disclosure CLI needs no skill file at all |
| noodletheworld | *"MCP is just \"me too\"; people want MCP to be an \"AI App Store\"; but the blunt, harsh reality is that it's basically impossible to achieve that dream"* | if you want an app, build an app |

Two mitigations get proposed against all of this, and both have filed defects. **cheema33** pre-empts the first: *"And no, the tool search function recently introduced by Anthropic does not completely solve this problem."* Anthropic's own documentation puts that mitigation at *"over 85 percent"* reduction on a five-server setup consuming *"~55k tokens in definitions before Claude does any work"* — real, and not total. **sophiabits** names the second-order cost of the other mitigation, loading servers lazily per task: *"The main problem with this approach at the moment is it busts your prompt cache, because LLMs expect all tool definitions to be defined at the beginning of the context window."*

Deferred loading has its own failure modes on record. In `anthropics/claude-code#57033`, servers added at claude.ai show Connected in `/mcp` and yet *"Any `ToolSearch` query that should match a claude.ai MCP tool returns zero results"*. In `openai/codex#24536`, *"`codex exec` can silently finish with no assistant message when an explicitly configured MCP tool is deferred behind `tool_search`."* A silent empty turn is worse than an expensive one.

[[embed:source:m4]]

## Round trips are the argument that survives bigger context windows

Token cost is a moving target. Context windows grow, caches improve, tool search lands. The structural argument does not move, and it is a different argument.

A tool call happens **between** completions. The model stops, the client executes, the result comes back, the model starts again. **DonHopkins** names the difference: *"I call this \"speed of light\" as opposed to \"carrier pigeon\"."* Code the model writes loops, recurses and composes **inside** one generation.

**martinald** gives the worked case — summing 150 order IDs. *"With MCP the agent would have to do 150 tool calls and explode your context."* One scripted loop does it in one round trip at roughly one percent of the tokens. He also notes there is no reason a harness could not expose MCP tools inside a sandboxed code environment, and that nobody does.

Two vendors have published the same finding independently. Anthropic: *"This reduces the token usage from 150,000 tokens to 2,000 tokens—a time and cost saving of 98.7%."* Cloudflare, on the same pattern: *"With the traditional approach, the output of each tool call must feed into the LLM's neural network, just to be copied over to the inputs of the next call, wasting time, energy, and tokens. When the LLM can write code, it can skip all that, and only read back the final results it needs."*

What that implies for exposing a catalogue is concrete, and it is not "abandon MCP":

1. Expose **one invoking tool that takes a key and arguments**, not one tool per capability. That is what the seven-tool surface does.
2. Make discovery a **call that returns data** — `list_directory`, or the protocol's resolver — so the catalogue is paged through rather than resident.
3. Keep an **HTTP path a code-execution sandbox can hit directly**, so a loop over 150 items is one round trip instead of 150.

**solarkraft** supplies the honest floor under all three: every capability costs some context, because the model has to know it exists in order to invoke it. The only question is whether it costs 523 compact bytes each or one line in a search result.

## `notifications/tools/list_changed` is in the specification, and named clients ignore it

The advertised benefit of a server-side catalogue is that adding a capability needs no client redeploy. The specification supplies the mechanism: *"When the list of available tools changes, servers that declared the `listChanged` capability **SHOULD** send a notification"* — `notifications/tools/list_changed`, a JSON-RPC notification with no parameters and no reply.

Whether that works depends on the client honouring it, and three filed reports say several do not:

| Client or system | Filed | Verbatim |
| --- | --- | --- |
| Kiro IDE — `kirodotdev/Kiro#6553`, closed | 2026-03-20 | *"When an MCP server dynamically adds or removes tools at runtime and sends this notification per the MCP spec, Kiro IDE does not re-query tools/list, so the new tools never appear until the server is manually reconnected."* |
| GitHub Copilot CLI — `microsoft/wassette#308`, open | 2025-09-29 | *"Internal terminal testing shows the CLI never refreshes its tool list, unlike GitHub Copilot in VS Code which updates immediately."* |
| MCPJungle gateway — `mcpjungle/MCPJungle#260`, open | 2026-05-15 | *"MCPJungle caches upstream tool lists at registration time only. If an upstream server adds or removes tools later, MCPJungle's view stays stale until a manual re-registration or full restart."* |

The wassette report comes from a Microsoft engineer with a reproduction video, and notes the same server updates immediately in VS Code — so the defect is per-client, not per-spec.

This site's per-row server sidesteps the question by declaring the truth instead of a promise. Line 159 of `functions/api/mcp.js` returns `capabilities: {"tools":{"listChanged":false}}`. A new row is live on the protocol surface the instant it is written, and appears on the MCP surface the next time a client calls `tools/list` — which, for most clients, is at connect. **"Add a capability without a redeploy" is true of the table and false of the client.** Say the second half out loud or the sentence is a lie.

## Verdict: publish MCP when the client is not yours, publish the protocol when it is

- **Do publish an MCP surface** when the consumer is a client you do not control and cannot teach — Claude Desktop, Cursor, ChatGPT connectors, a colleague's IDE. There is no other way in. Publish it as a **small surface**: one `dispatch`-style tool plus a discovery tool. Never one tool per row.
- **Do publish a per-row surface** only when a client must literally see named tools in its own interface, and only with that client's deferred loading switched on. Otherwise expect the bill: 149,187 input tokens and $0.02852109 per turn, measured.
- **Do not put MCP in front of your own agent** when you already control the harness. The protocol surface reached all 891 capabilities at 14,071 input tokens and $0.00456265 per turn, with nine tool definitions in context — all nine of them the client's built-ins.
- **Do not treat MCP as the architecture.** It is a view. The table is the thing.

**What would change this verdict.** A mainstream client that fetches definitions on demand rather than at connect, and honours `notifications/tools/list_changed`, would collapse the per-row surface's cost to roughly the protocol's and remove the reason to hand-build a small surface. Anthropic's tool search is the first move in that direction and already reaches 14,109 tokens on this catalogue — within 0.3% of the protocol-only figure. Two filed bugs, `claude-code#57033` and `codex#24536`, say it is not yet reliable enough to depend on. When those close and the behaviour is the default rather than a flag, publish per-row and stop hand-rolling.

## Publish the same catalogue three ways

Cheapest surface first, because it is also the one that works everywhere.

**A. Protocol only — no server to write.** Prerequisite: a capability table with a key, a runner, a target, a documentation field and an input schema (see [the row contract](/a/directory-row-contract)), plus one HTTP handler that looks a key up and runs it.

1. `GET /api/dispatch` returns a manifest naming the verbs and endpoint shapes. It was 17,404 bytes at 05:06:05Z.
2. `GET /api/dispatch?ask=<intent>` returns ranked candidates, each with an `example`, an `example_args` and a ready `run_now` URL.
3. `GET /api/dispatch?key=<KEY>&format=markdown` returns the exact row contract.
4. `POST /api/dispatch` with `{"key":"KEY","body":"args"}` invokes it. Add `"shape": true` for a dry run that returns the fully-shaped outbound payload without firing.
5. `GET /api/dispatch?receipt=<id>` returns the receipt.

Run the read-only `TIME_NOW` path end to end:

```bash
export TERMINAL_KEY="<owner access key>"

curl -sS "https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it" \
  | jq '{count, best: .best.key}'
# {"count":12,"best":"NOW"}

curl -sS "https://miscsubjects.com/api/dispatch?key=TIME_NOW&format=markdown" \
  | sed -n '1,12p'
# begins: ## §SELF — miscsubjects capability (paste without context)

curl -sS -X POST "https://miscsubjects.com/api/dispatch" \
  -H "x-terminal-key: $TERMINAL_KEY" -H "content-type: application/json" \
  --data '{"key":"TIME_NOW","body":""}' | tee invocation.json \
  | jq '{ran, result, invocation_id: .invocation.id}'
# ran is true; result contains now, today, time, zone and iso

INVOCATION_ID="$(jq -r '.invocation.id' invocation.json)"
curl -sS "https://miscsubjects.com/api/dispatch?receipt=$INVOCATION_ID" \
  -H "x-terminal-key: $TERMINAL_KEY" | jq '{id, object_id, actor}'
# id equals $INVOCATION_ID and object_id identifies TIME_NOW
```

The live run at 05:06:05Z returned HTTP 200 for all four calls, resolved `NOW`, invoked `TIME_NOW` with `ran: true`, and read a 13,036-byte receipt. Tell the model these five endpoint shapes once, in a system prompt or skill file. Cost in tool definitions: zero.

**B. Small surface — one file, seven tools.** Prerequisite: an MCP SDK and somewhere to run it. On Cloudflare that is `McpAgent` from the `agents` package on a Durable Object, `McpServer` from `@modelcontextprotocol/sdk`, and `zod` for parameter schemas. Register `dispatch` with `{key, body}`, register one discovery tool that queries the table, expose `/mcp` for Streamable HTTP and `/sse` for the older transport, and gate both behind a bearer check. Working file: `workers/mcp-server/src/index.ts`.

The deployed server can be counted with the official TypeScript SDK:

```bash
npm install @modelcontextprotocol/sdk
export TERMINAL_KEY="<owner access key>"

node --input-type=module <<'JS'
import {Client} from "@modelcontextprotocol/sdk/client/index.js";
import {StreamableHTTPClientTransport} from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://miscsubjects.com/mcp"),
  {requestInit:{headers:{authorization:`Bearer ${process.env.TERMINAL_KEY}`}}}
);
const client = new Client({name:"count-tools",version:"1"},{capabilities:{}});
await client.connect(transport);
const listed = await client.listTools();
console.log(listed.tools.map(tool => tool.name));
await client.close();
JS
```

Expected output, measured at 05:04:50Z: `d1_query`, `kv_get`, `kv_put`, `list_directory`, `dispatch`, `oip_registry`, `oip_invocations` — seven definitions totalling 2,165 compact JSON bytes. The count stays seven when the catalogue doubles because only `dispatch` and the discovery calls refer to catalogue keys.

**C. Per-row MCP — the compatibility surface.** One SELECT, one loop, one normaliser. The normaliser is the part people skip and then get bitten by: coerce every schema to a root `type: "object"`, ensure every name in `required` exists in `properties`, give every property node a `type`, and flatten `anyOf`/`oneOf`/`allOf` to a representative branch — or a strict client rejects the entire list on the first bad row. Verify with:

```bash
curl -s -X POST https://<host>/api/mcp -H "Authorization: Bearer $MCP_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python3 -c \
  "import json,sys;print(len(json.load(sys.stdin)['result']['tools']))"
```

Expected output: one integer, equal to the count of enabled, planner-visible rows.

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| Client shows zero tools and no error | The whole `tools/list` was rejected on the first invalid schema; Moonshot and Kimi validate strictly | Normalise every schema: object root, every `required` name present in `properties`, a `type` on every node, no combinators. `functions/api/mcp.js:44–78` |
| `401 unauthorized` from `/api/mcp` | Bearer token mismatch | Send `Authorization: Bearer <MCP_TOKEN>` or the `x-mcp-token` header. The check is `functions/api/mcp.js:23–28` |
| Tens of thousands of input tokens before the first user message | The client loads every definition at connect | Turn on the client's deferred tool loading, or move to a small surface |
| A new capability is invisible to the client | The client ignores `notifications/tools/list_changed`, or the server declares `listChanged: false` | Reconnect the client. The three filed reports above show reconnect is the reliable path |
| A tool exists in the table but not in `tools/list` | The row has `enabled = 0` or `planner_visible = 0` | Intentional. `planner_visible = 0` keeps a capability callable by key while off the tool surfaces |
| 150 items means 150 tool calls | Round trips happen between completions | Expose an HTTP path a code sandbox can loop over, or one tool that takes a batch |
| Prompt cache hit rate collapses after adding a server | Definitions sit at the head of the context, and changing them invalidates the cache | Keep the definition set fixed across a session; load per-task capabilities through a call, not a definition |
| `codex exec` returns an empty turn | A configured server was deferred behind `tool_search` and never surfaced | `openai/codex#24536`. Pin the server as directly exposed until it closes |

The comparison against staying inside the schema paradigm entirely is [Tool Search and catalogue-as-data](/a/tool-search-vs-catalogue-as-data).

## Sources

1. Model Context Protocol specification, revision 2025-06-18 — https://modelcontextprotocol.io/specification/2025-06-18
2. Tool search tool — https://docs.claude.com/en/docs/agents-and-tools/tool-use/tool-search-tool
3. Code execution with MCP: building more efficient AI agents — https://www.anthropic.com/engineering/code-execution-with-mcp
4. Code Mode: the better way to use MCP — https://blog.cloudflare.com/code-mode/
5. McpAgent API — Cloudflare Agents — https://developers.cloudflare.com/agents/model-context-protocol/mcp-agent-api/
6. Tools — MCP specification 2025-06-18 — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
7. Transports — MCP specification 2025-06-18 — https://modelcontextprotocol.io/specification/2025-06-18/basic/transports
8. JSON-RPC 2.0 Specification — https://www.jsonrpc.org/specification
9. modelcontextprotocol/typescript-sdk — https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x
10. Kiro IDE does not handle MCP notifications/tools/list_changed — dynamic tools not refreshed — https://github.com/kirodotdev/Kiro/issues/6553
11. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
12. Dynamic tool sync: notifications/tools/list_changed + polling fallback — https://github.com/mcpjungle/MCPJungle/issues/260
13. ToolSearch does not index tools from claude.ai-hosted MCP servers — https://github.com/anthropics/claude-code/issues/57033
14. codex exec can silently complete empty when configured MCP tools are deferred behind tool_search — https://github.com/openai/codex/issues/24536
15. Context bloat: 16.9k tokens wasted on MCP tool definitions loaded into main agent — https://github.com/yonatangross/orchestkit/issues/885
16. Reduce Context Window Usage (13,341 tokens for tools alone) — https://github.com/abdlkrim-jribi/hcode/issues/4
17. Comment on: Apideck CLI — An AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
18. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48336021
19. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381322
20. Comment on: MCP is dead; long live MCP — the contract is the value — https://news.ycombinator.com/item?id=47381282
21. Comment on: MCP is dead? — token cost is a harness detail — https://news.ycombinator.com/item?id=48331540
22. Comment on: MCP is a fad — it is not a wire protocol — https://news.ycombinator.com/item?id=46553245
23. Comment on: What if you don't need MCP at all? — https://news.ycombinator.com/item?id=45955033
24. Comment on: Agent Skills — speed of light versus carrier pigeon — https://news.ycombinator.com/item?id=46878126
25. Comment on: Making MCP cheaper via CLI — https://news.ycombinator.com/item?id=47161005
26. Comment on: MCP is dead? — the arithmetic, and both sides — https://news.ycombinator.com/item?id=48330912
27. Comment on: MCP is dead? — every capability costs some context — https://news.ycombinator.com/item?id=48337283
28. Comment on: When does MCP make sense vs CLI? — https://news.ycombinator.com/item?id=47212763
29. Comment on: Show HN: Epiq — skills versus MCP — https://news.ycombinator.com/item?id=48158034
30. Comment on: Running Gemma 4 locally with LM Studio's headless CLI and Claude Code — https://news.ycombinator.com/item?id=47659574
31. Comment on: Chrome DevTools MCP — tool search does not fully solve it — https://news.ycombinator.com/item?id=47392361
32. Comment on: When does MCP make sense vs CLI? — dynamic tools bust the cache — https://news.ycombinator.com/item?id=47209810
33. Comment on: Show HN: Ismcpdead.com — definitions that never fire — https://news.ycombinator.com/item?id=47646880
34. First-party measurement: tools/list against the per-row MCP surface, 2026-07-26 — https://miscsubjects.com/api/articles/mcp-as-a-projection
35. First-party measurement: the protocol surface reaches every capability with zero tool definitions, 2026-07-26 — https://miscsubjects.com/api/dispatch?registry=1
36. First-party receipt: initialize and tools/call against the live server, 2026-07-26 — https://miscsubjects.com/api/articles/mcp-as-a-projection
37. The source files behind the three surfaces, and the seven-versus-six correction — https://miscsubjects.com/api/articles/mcp-as-a-projection
38. First-party measurement: tools/list against the seven-tool MCP surface, 2026-07-26 — https://miscsubjects.com/


---

# One row of SQL is the whole contract for a capability, and the 892nd took 3.1 seconds

slug: directory-row-contract · https://miscsubjects.com/a/directory-row-contract · tags: tooling, oip, architecture, d1, contracts, mcp · updated 2026-07-26T03:52:45.831Z

A capability on this build is one row in a SQLite table on Cloudflare D1 called `directory`. The row is the whole contract: what the capability is, how to call it, what comes back, which credential it needs, and who is allowed to run it. There is no companion file, no registration call in application code, and no deploy step. On 2026-07-26 the table held 891 rows.

**Scope note:** this article covers the `directory` table's row shape only — the contract for API calls, shell commands, agents and other executable capabilities. It is one of at least two object families on this build; content (articles, their claims, their revisions) lives in a separate `articles`/`article_slots` pair of tables with its own resolver, not in `directory`. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) draws that line explicitly.

Every field below is published rather than paraphrased, every runner type has a real row printed as stored, and the failure strings are copied out of the code that emits them. The volume argument — why holding 891 tool definitions in a model's context is the wrong shape — is [tooling as data](/a/tooling-as-data). The call sequence around a row is [the four-step loop](/a/dispatch-four-step-loop). The Model Context Protocol view of the same rows is [MCP as a projection](/a/mcp-as-a-projection).

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## Eighteen columns, of which six existed on the first day

The table as it stands in production. Read it back yourself:

```bash
cd /Users/owner/miscsubjects-pages
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT sql FROM sqlite_master WHERE name='directory';"
```

```sql
CREATE TABLE directory (
  key        TEXT PRIMARY KEY,
  type       TEXT NOT NULL CHECK (type IN ('fn','http','agent','flow')),
  target     TEXT,
  auth       TEXT,
  content    TEXT,
  updated_at TEXT NOT NULL
, category TEXT, allowed_categories TEXT, seq INTEGER, enabled INTEGER DEFAULT 1,
  planner_visible INTEGER DEFAULT 1, planner_rank INTEGER DEFAULT 100,
  input_schema TEXT, examples TEXT, sensitive INTEGER DEFAULT 0, runner TEXT,
  includes TEXT, created_at TEXT)
```

The first six columns came from `migrations/0007_directory.sql:1-8`. Everything after the closing parenthesis of the original statement is an `ALTER TABLE` bolted on later, which is why the SQL reads the way it does.

| Column | Type · default | Required | What it holds | Real value |
| --- | --- | --- | --- | --- |
| `key` | TEXT, primary key | yes | The invocation name. Unique by constraint, uppercase by convention. | `GROK_MODELS` |
| `type` | TEXT, `CHECK IN ('fn','http','agent','flow')` | yes | Decides how `target` and `content` are read. The only constrained column in the table. | `http` |
| `target` | TEXT | by type | `fn`: a function name. `http`: `"METHOD url"`. `agent`: a model id. `flow`: empty. | `GET https://api.x.ai/v1/models` |
| `auth` | TEXT | no | The *name* of an environment variable and how to apply it. Never a secret value. | `bearer:GROK_API_KEY` |
| `content` | TEXT | yes, gated | `fn`/`http`: comment docstring plus the argument template. `agent`: the system prompt. `flow`: the step DSL. | see the four rows below |
| `updated_at` | TEXT, NOT NULL | yes | ISO timestamp of the last write. The only change marker on the row. | `2026-06-10 02:22:52` |
| `category` | TEXT | no | Grouping label. 110 distinct values live. | `grok` |
| `allowed_categories` | TEXT | no | On `agent` rows: the categories that agent's tool listing is restricted to, or `*`. | `*` |
| `seq` | INTEGER | no | Manual ordinal used to pin a row to a position. Null for almost every row. | `null` |
| `enabled` | INTEGER, default 1 | no | `0` hides the row from every projection. 13 rows are disabled. | `1` |
| `planner_visible` | INTEGER, default 1 | no | Whether planners and the MCP projection list it. | `1` |
| `planner_rank` | INTEGER, default 100 | no | Sort weight for candidate selection. Lower wins. | `100` |
| `input_schema` | TEXT | no | JSON Schema string, used when the row is projected as a typed tool. 256 rows carry one. | `null` |
| `examples` | TEXT | no | JSON array of worked argument strings. 63 rows carry one. | `["37.77\|-122.42"]` |
| `sensitive` | INTEGER, default 0 | no | `1` routes the call through the watcher before it runs. 227 rows are marked. | `0` |
| `runner` | TEXT | no | Overrides the runner inferred from `type`. 301 rows have a value. | `null` |
| `includes` | TEXT | no | On `agent` rows: comma-separated prompt-block keys composed in front of `content` at runtime. | `BLOCK_VOICE,BLOCK_REASONING_A` |
| `created_at` | TEXT | no | Present in the live table. **No migration in the repo adds it.** | `null` on old rows |
| `row_num` | computed, not stored | — | 1-based position in the canonical ordering, attached by the read path. | `412` |

Column provenance, by migration: `0012_directory_category.sql:7-9` added `category`, `allowed_categories`, `seq`. `0018_planner_columns.sql:5-9` added the five planning and schema columns. `0064_substrate.sql:5` added `runner`. `0118_add_directory_sensitive.sql:2` added `sensitive`. `0183_prompt_blocks.sql:2` added `includes`. `created_at` has no such line — `grep -rn "ALTER TABLE directory" --include="*.sql" .` returns fourteen matches and none of them mention it, so that column entered the production table out of band. A rebuild should add it in a migration.

Two columns exist in the table but cannot be written through the REST surface. The PATCH handler's field allow-list at `functions/api/directory/[key].js:223` contains thirteen names, and neither `sensitive` nor `runner` is one of them:

```bash
curl -sS -X PATCH "https://miscsubjects.com/api/directory/ZZ_TEST_TEMPERATURE" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"runner":"edge"}'
# {"error":"no recognized fields"}   HTTP 400
```

## The type column decides everything else, and it has exactly four legal values

`fn` runs a function inside the build's own Worker. `http` calls somebody else's API. `agent` sends `content` to a model as a system prompt. `flow` chains other rows. The dispatcher branches on the column at `functions/api/dispatch.js:1145-1156`; a fifth value is rejected by the `CHECK` constraint before that branch is reached.

| Type | Live rows | `target` holds | `content` holds | Pick it when |
| --- | --- | --- | --- | --- |
| `fn` | 480 | a key into the runner map | a JSON array template of the function's positional arguments | the work is code you control and want to run at the edge |
| `http` | 303 | `"METHOD url"`, with `$1` slots | the request body template, or nothing for GET | the work is an existing API |
| `agent` | 57 | a model id, e.g. `grok-4.3` or `gw:openai/gpt-4.1-mini` | the system prompt | the work needs judgement, not a deterministic call |
| `flow` | 51 | empty string | steps separated by `>`, each `KEY: args` | the work is two or more capabilities in order |

Counted live:

```bash
npx wrangler d1 execute loop-content-spine --remote \
  --command "SELECT type, COUNT(*) AS n FROM directory GROUP BY type ORDER BY n DESC;"
# fn 480 | http 303 | agent 57 | flow 51   → 891
```

One real row of each type, exactly as stored.

**`fn` — `NOW`**

```
key  NOW | type fn | target now | auth (null) | category time
content   # Return the current time from the build clock in Pacific time (America/Los_Angeles).
          # WHEN_TO_USE: any object or model that needs the current date or time.
          # ARGS: none
          # EX: [NOW][/NOW]
          # OUTPUT: { now, today, time, zone, iso } — Pacific-offset ISO; today is the Pacific calendar date.
```

Every line of `content` beginning with `#` is stripped before execution by `stripDocs` at `dispatch.js:440-452`. What is left is the executable payload. `NOW` has no payload line, so `runFn` falls back to the default template `["$1"]` at `dispatch.js:1170`.

**`http` — `GROK_MODELS`**

```
key  GROK_MODELS | type http | category grok | auth bearer:GROK_API_KEY
target    GET https://api.x.ai/v1/models
content   # WHAT: List every model on the xAI API. No args
          # WHEN_TO_USE: you need to grok models
          # ARGS: see content
          # EX: [GROK_MODELS][/GROK_MODELS]
          # List every model on the xAI API. No args.
```

**`agent` — `PROMPT_LAB_AGENT`**

```
key  PROMPT_LAB_AGENT | type agent | target grok-4.3 | auth bearer:GROK_API_KEY
content   You are a friendly peptide concierge (LAB TEST v1). One warm sentence,
          then end with [REPLY]your text[/REPLY].
```

An `agent` row with `includes` composes shared prompt blocks in front of `content` at runtime: `ROUTER` carries `BLOCK_VOICE,BLOCK_IMESSAGE,BLOCK_EMOJI,BLOCK_ROUTING,BLOCK_ARA`, so the voice rules are written once and referenced by six rows.

**`flow` — `BLOOIO_FINISH`**

```
key  BLOOIO_FINISH | type flow | target (empty)
content   # Phase C of the inbound turn: given the full agent output text in $1, extract the
          #   LAST [REPLY], send via blooio to $2, return the send result.
          # $1=agent output text. $2=recipient phone.
          LAST_REPLY_OF: $1
          > SEND_BY_CHANNEL: blooio|$2|$PREV
```

The flow reader splits on a top-level `>` and runs each step against the previous step's output, bound to `$PREV` (`dispatch.js:1686-1731`). A step is `KEY: body`. Appending `=> NAME` binds that step's output to `$NAME` for later steps. A `{ A: x | B: y }` block fans out concurrently. A step whose output starts with `ERR:` stops the flow.

## Arguments are one string, split on the pipe character, and that is a deliberate trade

The invocation body is a single string. The dispatcher splits it on `|` and hands the pieces to the runner as `args`:

```js
const args = String(body == null ? '' : body).split('|');
```

That is `dispatch.js:1144`. In a template, `$1` is the first piece, `$2` the second. Two arguments:

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"ZZ_TEST_TEMPERATURE","body":"37.77|-122.42"}'
```

`$1` becomes `37.77`, `$2` becomes `-122.42`, and the target `GET https://api.open-meteo.com/v1/forecast?latitude=$1&longitude=$2&current=temperature_2m` resolves to a real URL.

The obvious break: a pipe inside an argument. A JSON body, a prompt, a shell command, a sentence with a pipe in it — the naive split shreds all of them. The handled form is `$N+`, which rejoins arguments N through the end with the pipe put back:

```js
if (/^\d+\+$/.test(key)) {
  const v = args.slice(+key.slice(0, -1) - 1).join('|');
  return raw ? v : escFor(mode, v);
}
```

`dispatch.js:171-175`. So a row that takes a JSON blob as its last argument uses `$2+`, not `$2`. `DIR_PATCH` is the live example — it edits another row, so its second argument is arbitrary JSON:

```
key      DIR_PATCH
type     http
target   PATCH https://miscsubjects.com/api/directory/$1
content  # ARGS: key | json_body
         # EX: [DIR_PATCH]ROUTER|{"content":"new prompt text"}[/DIR_PATCH]
         $2+
```

Called as `body: 'ROUTER|{"content":"a|b"}'`, the key is `ROUTER` and the body template `$2+` receives `{"content":"a|b"}` intact. The rule that follows: **only the last argument of a row may contain a pipe, and only if the template uses `$N+`.** A row with two free-text arguments in the middle of its signature is unrepresentable, and that is the real cost of the format.

Substitution is escape-aware. `subVars` at `dispatch.js:156-201` takes a mode — `url`, `json-string` or `raw` — and escapes each value for the position it lands in, so a quote inside an argument cannot break out of a JSON body template. `$$KEY` skips the escaping. `$PREV` is the previous flow step's output. An unresolved `$NAME` is left in place as literal text rather than becoming an empty string.

Why one flat string and not a JSON object per capability: a caller that has read one contract can call any of the 891 without learning a new argument shape, and a router can forward a user's sentence through unchanged. The price is no types at the door. That is the trade one commenter refuses — menix, arguing schemas let a code-writing agent plan one precise program instead of a print-and-inspect loop. Both positions describe real failure modes; the comparison table below lands the verdict.

## The row names the environment variable; the value never enters the table

The `auth` column is a prefix and an environment-variable name. `applyAuth` at `dispatch.js:203-234` reads it at call time:

| Form | What happens | Live rows |
| --- | --- | --- |
| empty or null | no credential applied | 637 |
| `headers:{"k":"$ENV_NAME"}` | each header value has `$NAME` replaced from the environment | 139 |
| `bearer:ENV_NAME` | `Authorization: Bearer <value of ENV_NAME>` | 68 |
| `basic:ENV_NAME` | `Authorization: Basic ` + base64 of `<value>:` | 45 |
| `query:param=ENV_NAME` | appends `?param=<url-encoded value>` to the URL | 2 |
| anything else | throws `ERR:auth:unknown_prefix:<prefix>` | 0 |

```bash
npx wrangler d1 execute loop-content-spine --remote --command \
"SELECT CASE WHEN auth IS NULL OR TRIM(auth)='' THEN '(none)'
        ELSE substr(auth,1,instr(auth,':')) END AS form, COUNT(*) AS n
 FROM directory GROUP BY form ORDER BY n DESC;"
```

115 rows name a credential through `bearer:`, `basic:` or `query:`, and between them they reference **12 distinct auth specifications**. `bearer:GROK_API_KEY` alone appears on 35 rows. One rotation in the platform secret store changes the credential for all 35; no row is touched, no migration runs, no deploy happens.

An attacker who exfiltrates the whole table learns every capability that exists, every upstream URL, every argument shape, which capabilities are credentialed, and the *names* of the twelve secrets. That is a real map, worth defending. What they do not get is one credential value, because no column ever holds one. The failure mode of a leaked registry that stores values is total; here it is reconnaissance.

The row's claims about permission are advisory. Enforcement is server-side and independent of the row. When an invocation arrives with a scoped capability token, `capGateCheck` at `dispatch.js:2121-2149` evaluates revocation, audience binding, owner gate, contract hash, risk ceiling, fixed body and payload ceiling before any runner is reached. A row marked `sensitive = 1` is denied to any token whose `risk_ceiling` is not `high`, with the literal reason `risk_ceiling:low<row:high`. Editing the row cannot widen a token; editing a token cannot reach a row outside its scope. That is the pattern jensbontinck described from production — the credential sits at the enforcement point, not with the caller.

Shape mode proves the boundary without firing anything: `{"key":"…","body":"…","shape":true}` returns the fully constructed outbound request with credential material stripped by `redactDeep` (`dispatch.js:1338-1356`), which removes `authorization`, `x-api-key`, `cookie` and any `*_API_KEY`-shaped string. Against a row whose `auth` named a variable absent from the environment, the preview came back `"headers":{}` — no credential, no header, and the upstream 401 is the first signal.

## Reading one row returns a document that assumes the reader knows nothing

```bash
curl -s "https://miscsubjects.com/api/dispatch?key=GROK_MODELS&format=markdown"
```

4,413 bytes. The blocks it contains, and why each is there:

| Block | Content | Why it exists |
| --- | --- | --- |
| Path | `OIP > GROK > GROK_MODELS` | places the row in the tree so a reader can climb to siblings |
| Capability / When to use | the `# WHAT` and `# WHEN_TO_USE` lines from `content` | the two questions a caller has before choosing |
| RUN NOW | a single URL that fires the example | a model with only a URL-fetch tool can still invoke it |
| Example call | `[GROK_MODELS][/GROK_MODELS]` | the router tag form, for a model emitting tags in prose |
| type · runner · auth · risk | `tool · edge · grok`, `required · low` | tells the caller whether a credential and an approval are needed before trying |
| inputs / outputs | `{"args":"see content"}` and the documented return shape | the argument contract |
| Affordances | the moves the presented credential can make | computed for the caller, with the note that the server enforces scope regardless |
| Machine Contract | four imperatives, including "do not infer the row shape from memory" | stops a model reconstructing a stale signature from training data |
| Invocation / Ledger / Repair | ledger, receipt, replay and repair URLs | closes the loop after the call |
| Troubleshooting | four problems, each with an action and a URL | the same content as the failure table below, at the point of use |

The contract is close to constant in size regardless of the row. Measured across four rows of four different types: `NOW` 4,423 bytes, `GROK_MODELS` 4,413, `ROUTER` 4,442, `CONTENT_SEARCH` 4,507. The per-row variance is under 100 bytes because the scaffolding dominates and the row-specific part is small — that is the shape of a contract that is fetched one at a time rather than held in context. Fetching all of them at once is the opposite bargain: `curl -s "https://miscsubjects.com/api/dispatch?registry=1" | wc -c` returns 1,608,554 bytes for 877 objects.

People running large tool surfaces keep measuring the same thing independently: a maintainer auditing his own agent found 47 tool schemas costing 13,341 tokens on every request before the user's message; a vendor engineer building an MCP server for a large unified API hit 50,000 tokens of definitions before the agent touched a single user message; a tiktoken run against one server's full tool list produced 741 tools and roughly 488,013 tokens, larger than the context window it was meant to fit.

## Adding the 892nd capability: one POST, then a receipt proving it ran

Everything below was executed against production on 2026-07-26 using an obviously-named throwaway row, `ZZ_TEST_TEMPERATURE`, which was deleted at the end. The outputs are copied verbatim.

**Step 1 — the credential.** `TERMINAL_KEY` is the owner key checked by `isBuildAuthed`. Every mutating call carries it as `x-terminal-key`.

```bash
export TERMINAL_KEY="$(grep '^TERMINAL_KEY=' ~/.config/grok-bridge.env | cut -d= -f2 | tr -d '"')"
```

**Step 2 — the POST.** `key` and `type` are the only required fields (`functions/api/directory/index.js:51`).

```bash
curl -sS -X POST https://miscsubjects.com/api/directory \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' -d '{
    "key": "ZZ_TEST_TEMPERATURE",
    "type": "http",
    "target": "GET https://api.open-meteo.com/v1/forecast?latitude=$1&longitude=$2&current=temperature_2m",
    "auth": "",
    "content": "# WHAT: Current temperature in Celsius for one latitude and longitude, from the Open-Meteo public API.\n# WHEN_TO_USE: a capability needs the live temperature at a coordinate.\n# ARGS: $1=latitude | $2=longitude\n# EX: [ZZ_TEST_TEMPERATURE]37.77|-122.42[/ZZ_TEST_TEMPERATURE]\n# OUTPUT: JSON with current.temperature_2m",
    "category": "tools",
    "enabled": 1,
    "planner_visible": 1,
    "examples": "[\"37.77|-122.42\"]"
  }'
```

```json
{"ok":true,"key":"ZZ_TEST_TEMPERATURE","updated_at":"2026-07-26T04:36:19.832Z"}
```

HTTP 201. Without the header the same call returns `{"error":"unauthorized"}` and HTTP 401.

**Step 3 — invoke it.** No deploy, no restart, no cache warm-up in between.

```bash
curl -sS -X POST https://miscsubjects.com/api/dispatch \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"ZZ_TEST_TEMPERATURE","body":"37.77|-122.42"}' \
  -w "\nhttp=%{http_code} total=%{time_total}s\n"
```

```json
{"ok": true, "ran": true, "trace": "t_jweoafmw",
 "result": "HTTP 200:{\"latitude\":37.763283,\"longitude\":-122.41286,...,\"current\":{\"time\":\"2026-07-26T04:30\",\"interval\":900,\"temperature_2m\":15.7}}",
 "proof": {"ok": true, "invocation_id": "inv_c23irnzhx1",
           "public_receipt": "https://miscsubjects.com/receipt/inv_c23irnzhx1"}}
```

`http=200 total=3.126264s` — 3.13 seconds wall clock from a laptop in California, including the Open-Meteo round trip. Three consecutive invocations of a pure `fn` row with no upstream call, timed the same way, took 3.40 s, 1.07 s and 1.59 s; the first carries TLS and connection setup.

**Step 4 — the receipt.** `inv_c23irnzhx1` records actor `owner:terminal-key`, runner `http`, trace `t_jweoafmw`, `material: true`, `cost_usd: 0`, and three SHA-256 fingerprints: input, output, and the object contract. It is public and keyless:

```bash
curl -s "https://miscsubjects.com/api/dispatch?confirm=inv_c23irnzhx1"
# "confirmed": true, "status": "PROVEN_MATERIAL_RESULT"
```

**Step 5 — remove it.**

```bash
curl -sS -X DELETE "https://miscsubjects.com/api/directory/ZZ_TEST_TEMPERATURE" \
  -H "x-terminal-key: $TERMINAL_KEY"
# {"ok":true,"key":"ZZ_TEST_TEMPERATURE","deleted":1}
```

A subsequent `GET /api/directory/ZZ_TEST_TEMPERATURE` returns 404. The receipt still resolves and still reports `confirmed: true` — deleting the definition does not delete the history of what it did.

## The no-restart property depends on something outside the row

Nothing here caches a tool list on the caller's side, so a new row is live on the next read. That is not a general property of tool registries, and the people who run them keep filing the same bug.

A registry gateway maintainer describes the failure exactly: upstream tool lists cached at registration time, stale until a manual re-registration or a full restart, with removed tools leaving dangling references inside tool groups. The Model Context Protocol has a message for this — a server that declared the `listChanged` capability SHOULD send `notifications/tools/list_changed` when its tool list changes. Two independent reports say clients ignore it. One user reproduced a dynamic-registration server against an IDE that never re-queries `tools/list`, while the same server worked in two other clients. A Microsoft engineer filed identical behaviour against a CLI client, with a repro video, noting the same product's editor extension updates immediately.

"Add a capability without a restart" is therefore a claim about the whole path, not about the registry. This path has no client-side cache to invalidate because the caller fetches one contract at a time. A registry that pushes definitions into a client's context inherits that client's refresh behaviour, and the behaviour is not uniform.

## Every failure names itself, and the names are in the code

| Symptom | Literal response | Cause | Fix |
| --- | --- | --- | --- |
| Call returns immediately, nothing ran | `{"error":"unknown_key","attempted":"ZZ_TEST_TEMPERATUR","ran":false,"did_you_mean":[…]}` | key not in the table | use a `did_you_mean` entry, or `GET /api/dispatch?ask=<plain words>` |
| `fn` row fails before running | `ERR:fn:unknown_target:<name>` | `target` names a function absent from the runner map (`dispatch.js:1169`) | correct `target`, or the function was renamed in a deploy |
| `fn` row fails on its own template | `ERR:fn:bad_content_json:<parser message>` | the executable line of `content` is not valid JSON after substitution (`dispatch.js:1173`) | the template must be a JSON array; check for an unescaped quote in an argument |
| `fn` template parses but is rejected | `ERR:fn:content_not_array` | the template is valid JSON but not an array (`dispatch.js:1174`) | wrap it: `["$1","$2"]` |
| Credential-shaped `auth` never applies | request goes out with `"headers":{}` | `auth` names an environment variable that does not exist | add the secret under the exact name; the row does not change |
| Auth string is malformed | `ERR:http:<KEY>:ERR:auth:unknown_prefix:apikey` | prefix is not one of `bearer:`, `basic:`, `headers:`, `query:`, `oauth:` (`dispatch.js:234`) | use a supported prefix |
| Upstream refuses | `ERR:http:401:<body>` | the credential exists but is rejected | rotate the secret; the row is fine |
| Every call to one row fails instantly after a run of 401s | `ERR:breaker_open:<KEY> — 8 consecutive auth failures; credential is dead until replaced.` | the circuit breaker tripped at 8 consecutive 401/403 (`dispatch.js:1293-1320`) | replace the credential; the breaker clears after 1 hour or on `KV delete breaker:<KEY>` |
| Target host unreachable | `ERR:http:fetch:<message>` | DNS, TLS or connection failure (`dispatch.js:1285`) | check the URL in `target` |
| Argument missing | URL renders with an empty slot, e.g. `&longitude=&` | fewer pipe-separated pieces than the template's `$N` slots | count the `$N` slots; extra arguments beyond the highest slot are silently discarded |
| `flow` step fails | `ERR:flow:bad_step:<text>` | a step has no `:` separating key from body (`dispatch.js:1723`) | write `KEY: args` |
| Write refused, nothing changed | `{"error":"registry_hygiene_refused: missing_description","how_to_fix":"content (the docstring…) is required…","state_changed":false}` | PUT/PATCH would leave the row with empty `content` (`[key].js:142-153`) | write the `# WHAT / # ARGS / # EX` docstring |
| Marking a row sensitive is refused | `{"error":"registry_hygiene_refused: high_risk_missing_schema"}` | `sensitive` set without `input_schema` | supply `input_schema` in the same call |
| PATCH accepted no changes | `{"error":"no recognized fields"}` HTTP 400 | the body named only fields outside the allow-list (`[key].js:223`) | `sensitive` and `runner` are not writable through PATCH |
| Token rejected on a row it should reach | `risk_ceiling:low<row:high` | the row is `sensitive` and the token's ceiling is not `high` | mint a token with the higher ceiling; the row is not the problem |
| Token rejected after an unrelated edit | `contract_changed:<pinned>!=<current>` | the token pinned a contract hash and the row's contract changed | mint a fresh token against the new contract |

The hygiene gate is asymmetric on purpose. `PUT` refuses any non-compliant write. `PATCH` compares the violation before and after the merge and refuses only a patch that makes a compliant row non-compliant, so the rows that predate the rule stay editable for unrelated maintenance (`[key].js:206-219`). Of 891 rows, 256 carry an `input_schema` and 63 carry `examples`.

## Where the row loses to a schema, and where a schema loses to the row

| Dimension | Directory row | JSON Schema tool definition | OpenAPI 3.1 operation | MCP tool |
| --- | --- | --- | --- | --- |
| Argument typing before the call | none; one string, split on `\|` | full — types, enums, required, formats | full, plus content negotiation and parameter locations | full; `inputSchema` is a JSON Schema object |
| Rejects a bad call without executing | no; the runner or the upstream rejects it | yes, at the validator | yes, at the gateway or generated client | yes, if the host validates |
| Client code generation | none | partial | mature; the spec exists so consumers can act "without access to source code" | via generated SDKs over the schema |
| Ecosystem and tooling | one implementation, this one | universal | very large | growing fast, multi-vendor |
| Discovery | `?ask=` in plain words, or `?registry=1` | out of band | the document is the discovery surface | `tools/list` |
| Cost in caller context | one contract, ~4.4 KB, fetched when needed | every definition, every request | n/a in-prompt; large as a document | every definition, every request unless the host defers |
| Adding a capability | one INSERT, live immediately | edit the tool array, redeploy the caller | edit the document, regenerate clients | server-side change plus a `list_changed` the client may ignore |
| Change safety for callers | contract hash on the receipt, detected after the fact | schema diff, detected by tooling | versioned document, diffable | schema diff, if the client re-reads |

The verdict. Use a schema when a wrong call is expensive and must be stopped before it executes — money movement, destructive writes, anything where "the upstream returned 400" is already too late — or when strangers will write clients against it. Typing is not decoration there; it is the only place a bad argument is caught for free. Use a row when the surface is large, the caller is a model, the operations are mostly read-and-report, and the dominant cost is context rather than correctness.

The two are not exclusive. `input_schema` is on the row for this reason: 256 rows carry one, and those are the rows that get typed when the table is projected as an MCP tool list. The row is the storage format; the schema is one projection of it.

## A row has no version number, and the receipt is where change is detectable

`updated_at` is overwritten on every write. There is no version column, no history table, and no diff. What exists instead is a fingerprint computed at invocation time. `objectContractFingerprint` (`functions/_lib/object_contract.js:17-25`) hashes the fields that define the call — id, object type, runner, directory type, category, target, description, `input_schema`, auth, risk, approval requirement, status, operation semantics — and the resulting SHA-256 is stored on every receipt as `fingerprints.contract`.

Which fields count was measured directly, by invoking the same row three times with an edit in between:

| Edit between invocations | Invocation | Contract fingerprint |
| --- | --- | --- |
| — (baseline) | `inv_vbzo55gyxb` | `fcb5a4d6ccdd552994c09c96a88c6a1dc18470d2fb1b57c151b4b951e4a7342f` |
| `PATCH {"examples":"[\"51.51\|-0.13\"]"}` | `inv_7s7br5887e` | `fcb5a4d6ccdd552994c09c96a88c6a1dc18470d2fb1b57c151b4b951e4a7342f` |
| `PATCH {"target":"…&current=temperature_2m,wind_speed_10m"}` | `inv_m6c161ry9u` | `da066c304231231b32002f04aebb513f8a62bed41646e4b16845cb25c8c7fadc` |

Changing `examples` left the fingerprint byte-identical. Changing `target` changed it. So the hash tracks the callable contract and ignores documentation-only edits — which is the behaviour you want, and it is worth knowing rather than assuming.

Two guarantees follow, and one gap. A capability token may be minted pinned to a contract hash; any invocation after the row changes is refused with `contract_changed:<pinned>!=<current>` and HTTP 409 (`dispatch.js:2128-2132`), so the caller is stopped rather than silently redirected. And every mutation is written to the append-only event log as a `DIRECTORY_MUTATE` record carrying the action, the key and the row (`[key].js:29-45`) — the change history exists even though the row does not keep it.

The gap: an unpinned caller invoking a changed row gets the new behaviour with no warning and learns of it from the receipt afterwards. That is the limitation, and the reason pinning exists. It is the same distinction aderix drew about version-controlling capabilities in general — approval around the definition does not help mid-flight, because the dangerous moment is the invocation, not the edit.

Receipts are engine-authored, never agent-authored, and that is not stylistic. A controlled two-condition experiment found an agent inventing a governance event that never happened and presenting it as compliance evidence when nothing else wrote the record. Here the dispatcher writes it, in the same code path that runs the call, with hashes of the actual input and output bytes.


## Sources

1. Cloudflare D1 overview — https://developers.cloudflare.com/d1/
2. Tool use overview — Claude Docs — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
3. Model Context Protocol — Tools (2025-06-18) — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
4. JSON Schema Validation, draft 2020-12 — https://json-schema.org/draft/2020-12/json-schema-validation
5. OpenAPI Specification 3.1.0 — https://spec.openapis.org/oas/v3.1.0.html
6. modelcontextprotocol/schema.ts — the Tool interface — https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-06-18/schema.ts
7. Dynamic tool sync: notifications/tools/list_changed + polling fallback — https://github.com/mcpjungle/MCPJungle/issues/260
8. Kiro IDE does not handle MCP notifications/tools/list_changed — dynamic tools not refreshed — https://github.com/kirodotdev/Kiro/issues/6553
9. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
10. Comment on: Agentic Engineering Patterns — https://news.ycombinator.com/item?id=47263727
11. Comment on: Polymcp – toolkit for building MCP agents that discover, inspect and orchestrate tools — https://news.ycombinator.com/item?id=46487491
12. Reduce Context Window Usage (13,341 tokens for tools alone) — https://github.com/abdlkrim-jribi/hcode/issues/4
13. Comment on: Apideck CLI – An AI-agent interface with much lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
14. GCORE_TOOLS=* advertises ~488k tokens of tool definitions — larger than most context windows — https://github.com/G-Core/gcore-mcp-server/issues/14
15. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381282
16. Comment on: Show HN: GitAgent – An open standard that turns any Git repo into an AI agent — https://news.ycombinator.com/item?id=47417059
17. Comment on: Agent Runs Code You Never Wrote — https://news.ycombinator.com/item?id=47579314
18. Comment on: Ask HN: How are you enforcing permissions for AI agent tool calls in production? — https://news.ycombinator.com/item?id=46747408
19. Add sanitized audit logging contract for MCP tool calls — https://github.com/rafaself/aws-mcp-gateway/issues/21
20. Row counts by type, taken from production D1 on 2026-07-26 — https://miscsubjects.com/api/dispatch?registry=1
21. Credential forms and registry hygiene across all 891 rows — https://miscsubjects.com/api/directory
22. Receipt for the first invocation of a capability created minutes earlier — https://miscsubjects.com/receipt/inv_c23irnzhx1
23. Contract size, measured across four rows of four different types — https://miscsubjects.com/api/dispatch?key=GROK_MODELS&format=markdown
24. Which row edits change the contract fingerprint, measured by three invocations — https://miscsubjects.com/api/dispatch?confirm=inv_m6c161ry9u
25. The production CREATE TABLE and column list, read back from D1 — https://miscsubjects.com/api/directory
26. cloudflare/workers-sdk — wrangler, the tool every measurement here was taken with — https://github.com/cloudflare/workers-sdk


---

# Nine tool definitions reach every capability: the catalogue is a SQL table, not a prompt

slug: tooling-as-data · https://miscsubjects.com/a/tooling-as-data · tags: tooling, oip, mcp, architecture, tool-search, context-engineering, cost · updated 2026-07-26T03:52:41.331Z

A model's capabilities do not have to live in its context. On this build they live in a SQLite table on Cloudflare D1 called `directory` — one row per capability, reachable through one HTTP endpoint — and the model is shown nine tool definitions. Not nine capabilities. Nine definitions, and every row in the table behind them.

**Scope note:** this measures one thing — what it costs to expose *this* catalogue to a model three ways. It is not a claim that the catalogue is the whole architecture. [892 rows, 8 of them MCP](/a/the-directory-is-not-the-object-system) breaks the same `directory` table down by runner and category: eight rows are tagged `category='mcp'`; the rest are API calls, shell commands, Mac-local actions and agents. It also names the separate `articles` table and resolver, which this table and its `dispatch()` function do not cover.

The default in every agent stack is the opposite: each capability is a tool definition, each definition is JSON Schema, and the whole set is transmitted on every request. That puts catalogue size in the per-turn cost equation. This design takes it out.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## The same catalogue, exposed three ways, measured on the same day

One build, one table, one model. Only the exposure changes. Measured 2026-07-25 on `@cf/moonshotai/kimi-k2.7-code` through Cloudflare AI Gateway, `claude-cli` 2.1.165 as the client, figures read from gateway log rows and wire captures rather than estimated.

| Exposure | What the request carries | Input tokens, one turn | Cost, that turn |
| --- | --- | --- | --- |
| One MCP tool per row (`POST /api/mcp`, `tools/list`) | 856 tool definitions | 149,187 | $0.02852109 |
| Same server, host defers the definitions (`ENABLE_TOOL_SEARCH=true`) | 9 definitions + a search tool | 14,109 | $0.00443075 |
| No MCP server attached; capabilities reached over HTTP | 9 built-in tool definitions | 14,071 | $0.00456265 |

All three rows reach the same capabilities. The first costs 10.6× the input tokens of the third for identical reach.

## The honest finding: at this size the two cheap designs cost the same

14,109 against 14,071 is a difference of 38 input tokens, 0.27%. In dollars the deferred-tool turn came out 3.0% cheaper, because the turn totals include output tokens and the two turns did not produce identical output. Anyone reading this page for a cost argument between rows two and three will not find one. **At 891 rows, deferred tool search and catalogue-as-data are the same price.**

The difference is structural, and it does not expire when the numbers do:

- Row two's cost is a function of how many definitions the model retrieves. Row three's is a function of the protocol, which is four endpoints regardless of table size.
- Row two needs the host to implement deferral. Row three needs the model to be able to make an HTTP request.

What would falsify the structural claim: a harness where deferred loading is free at any catalogue size *and* is implemented uniformly across clients. Section "Deferral is a host feature, and hosts disagree about it" is where that claim currently breaks. What would falsify the cost claim: a catalogue an order of magnitude larger, where the retrieved-definition cost of row two starts to bite while row three stays flat. That measurement has not been taken here and is not claimed.

## `defer_loading` controls context, not the request

The vendor documentation is explicit about what deferral does and does not remove, and it is the single most load-bearing fact on this page:

> `defer_loading` controls what enters the context window, not what you send in the request: You still send every tool's full definition in the `tools` array on every request, including the deferred ones. The API needs them server-side to run the search and expand `tool_reference` blocks.

— Anthropic, *Tool search tool*

So the catalogue is still enumerated, still serialised, still transmitted, every turn. It is simply not billed as context. On this build that array would be 831 tool objects and 451,197 bytes of JSON, measured live below. The client is doing that work whether or not the model reads it.

Anthropic also publishes the billing rule that makes definitions expensive in the first place: pricing counts "the total number of input tokens sent to the model (including in the `tools` parameter)". Names, descriptions and schemas are input tokens. They are re-sent every turn. They scale with how many capabilities exist.

Two independent numbers put a floor under that. Anthropic's own doc says a five-server setup — GitHub, Slack, Sentry, Grafana, Splunk — "can consume ~55k tokens in definitions before Claude does any work". The Scalekit benchmark, 5 GitHub tasks against `anthropics/anthropic-sdk-python`, Claude Sonnet 4, pre-registered hypotheses and 30 runs per arm, found the simplest task cost 1,365 tokens through a shell and 44,026 through GitHub's MCP server, and attributes it: "The difference is almost entirely schema: 43 tool definitions injected into every conversation, of which the agent uses one or two."

Divide this build's own numbers the same way. (149,187 − 14,071) ÷ 856 = **157.8 input tokens per definition**. That is close to the back-of-envelope a commenter used on Hacker News — "Say each tool is 150 tokens, that's 150 * 50, or 7500 tokens, dumped into the beginning of every session" — which means the per-definition constant is stable enough to plan against.

## One row is one capability, and this is one row

`AIG_LIST` lists the AI Gateways on a Cloudflare account. Read live with:

```
curl -s "https://miscsubjects.com/api/directory/AIG_LIST" -H "x-terminal-key: $TERMINAL_KEY"
```

Every field it carries, verbatim:

| Field | Value in `AIG_LIST` | What it does |
| --- | --- | --- |
| `key` | `AIG_LIST` | Primary key and invocation name. The only identifier a caller needs. |
| `type` | `http` | One of `fn`, `http`, `agent`, `flow`. Decides which runner executes the row. |
| `target` | `GET https://api.cloudflare.com/client/v4/accounts/$1/ai-gateway/gateways` | Where the work happens. `$1` is the first positional argument. |
| `auth` | `bearer:CLOUDFLARE_API_TOKEN` | The **name** of the environment variable holding the credential. Never the credential. |
| `content` | `# WHAT: List AI Gateways on the account`<br>`# WHEN_TO_USE: you need to aig list`<br>`# ARGS: account_id`<br>`# EX: [AIG_LIST][/AIG_LIST]` | Docstring lines then the argument template. The `#` lines are the contract a model reads; everything after them is the executable payload. |
| `category` | `null` | Grouping tag. Used to filter the registry (`?registry=1&category=…`). |
| `planner_rank` | `100` | Sort order when a planner is choosing between candidates. Lower ranks first. |
| `enabled` | `1` | `0` removes it from every projection without deleting the history. |
| `planner_visible` | `1` | `0` keeps it invocable but hides it from planners and from the MCP projection. |
| `input_schema` | `null` | Optional JSON Schema. Only consulted when the row is projected as an MCP tool. |

The field list is not folklore — it is declared in code at `/Users/owner/miscsubjects-pages/functions/_lib/dir_schema.js` lines 6–29, which is embedded in `/api/directory` responses so a client can learn the shape without prior knowledge. The docstring parser that splits `#` lines from the payload is `extractDocs`/`stripDocs` in `functions/api/dispatch.js` lines 431–450.

The full field reference, all four `type` values and what each runner does: [What a directory row is](/a/directory-row-contract).

## Four counts of the same catalogue, all of them correct

Ask the build how many capabilities it has and you get four different numbers. They are not a bug and they must not be reconciled by editing one to match another. Each is a different predicate over the same table.

Taken live at **2026-07-26T04:37:42Z**:

```
npx wrangler d1 execute loop-content-spine --remote --command \
  "SELECT COUNT(*) AS rows_total,
          SUM(CASE WHEN IFNULL(enabled,1)=1 THEN 1 ELSE 0 END) AS enabled,
          SUM(CASE WHEN IFNULL(enabled,1)=1 AND IFNULL(planner_visible,1)=1 THEN 1 ELSE 0 END) AS mcp_projected
   FROM directory;" --json
```

| Number | Surface it appears on | The predicate | Where the predicate lives |
| --- | --- | --- | --- |
| **892** | The table itself | every row | `SELECT COUNT(*) FROM directory` |
| **879** | `GET /api/dispatch?map=1` → `total` | `IFNULL(enabled,1)=1` | 13 rows are disabled and stay in the table for their history |
| **877** | `GET /api/dispatch?registry=1` → `count` | enabled, minus test-shaped keys | `TEST_ID_PATTERN` at `functions/_lib/object_contract.js:2477`, applied at `:2481-2483` |
| **832** | `POST /api/mcp` `tools/list` | `IFNULL(enabled,1)=1 AND IFNULL(planner_visible,1)=1` | `listTools()` at `functions/api/mcp.js:118-123` |

The measurement day's figures were 891 / 878 / — / 856. The table is live and other writers touch it, so a rerun returns whatever it holds at that instant; between the first and last command in this session a row was inserted by another process. That is the point of the design, not an inconvenience to it. The gap between 892 and 832 — 60 rows — is entirely disabled rows plus rows deliberately hidden from planners.

## Counting the projection, live

The MCP projection is a real server and the definition array can be weighed directly. Command:

```
curl -s -X POST https://miscsubjects.com/api/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Result at 2026-07-26T04:38Z: **831 tools**, **451,197 bytes** of `tools` array, mean **543 bytes per definition**. That is the payload a client sends on every request under `defer_loading`, and the payload a model reads without it.

## A model that has never seen this build gets from question to receipt in four calls

No SDK, no client library, no prior knowledge. Four HTTP calls.

**1 — Ask in plain language.** `GET https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it` returns `count: 12`, a `best` block, and twelve ranked candidates each with a runnable URL:

```json
"best": { "key": "NOW",
          "run_now": "https://miscsubjects.com/api/dispatch?invoke=NOW&share=<TOKEN>",
          "do": "Open run_now to do it. Substitute your own text/args where the example has them." }
```

**2 — Read the contract.** `GET https://miscsubjects.com/api/dispatch?key=NOW&format=markdown` returns that capability's `_self` block: what it is, the exact POST shape, the argument template, the output contract, the auth and risk level, the troubleshooting table, and the ledger and repair addresses.

**3 — Invoke.** `POST https://miscsubjects.com/api/dispatch {"key":"NOW","body":""}`.

**4 — Take the receipt.** The response carries `proof.invocation_id` and three addresses: a credentialed forensic receipt, a keyless public confirmation, and a public brochure.

Each step with its full request and response: [The four-step loop](/a/dispatch-four-step-loop).

## The resolver is a substring scorer, and the tail of its output is noise

`answerAsk` at `functions/_lib/object_contract.js:605-640` scores every enabled row: +3 if a query term appears in the key, +1 if it appears anywhere in key, category or docstring, +1000 for a hand-pinned canonical match, −6 for a row on the demote list. Top twelve are returned.

For `?ask=what is it` the pinned answer is right and the rest is garbage. The live twelve for "what time is it":

`NOW`, `GITHUB_LIST_ISSUES`, `GITHUB_GET_ISSUE`, `GITHUB_ADD_ISSUE_COMMENT`, `GITHUB_CREATE_ISSUE`, `GITHUB_CLOSE_ISSUE`, `LOCAL_EDIT`, `LOCAL_WRITE`, `CLI_GIT`, `WRITER_AGENT`, `BLOOIO_LIST_CONTACT_IDENTITIES`, `STRIPE_INVOICE_ITEMS_LIST`

The GitHub rows match because the two-character term `is` is a substring of `issue`. There are no embeddings, no BM25, no stemming and no synonym table. A query using a word the row never uses will miss it. Anthropic's own tool search offers BM25 and regex variants for exactly this reason, and one commenter names the trade honestly: "Seems like we traded scalability for accuracy, then accuracy for scalability… but I guess maybe we've come out on top because whatever they are using for tool search is better than RAG?" On this build the mitigation is the `recommended` pin, which is a hand-maintained list, which is a real maintenance cost.

## What a row carries that a list of endpoints does not

"Put it behind an API" is not the same design. Five things live in the row that a bare endpoint list leaves to convention:

| The row carries | A bare endpoint list has | Why it matters to a model |
| --- | --- | --- |
| A docstring contract (`WHAT` / `WHEN_TO_USE` / `ARGS` / `EX`) | A path and a method | The model learns *when* to call it, not just how. |
| An auth field naming an environment variable | A credential the caller must already hold | The catalogue is publishable; the secret never appears in it. |
| A receipt per invocation, addressable | Whatever the server logged | Failure is inspectable at a URL instead of narratable. |
| A repair address (`repairs: inv_ID`) | A retry | A corrected call is linked to the failed one; lineage closes. |
| `enabled` / `planner_visible` flags | A deploy | Withdrawing a capability is an `UPDATE`. |

## The strongest objection to all of this, from someone who means it

The case against is not "MCP is fine". It is that a decorated index of features beats a bare endpoint list for a model, and that decoration is the whole product:

> It's like saying APIs are dead because you can just use HTTP. They're not the same thing, though of course you can hand-roll the higher layer in the lower one. It's just more work, less standard, less valuable.

— brookst, Hacker News, 2026-05-30

That is correct as stated, and this design does not contradict it. The `directory` row *is* the decorated index: the decoration is the docstring, the auth field, the schema and the flags. What is rejected is the claim that the decoration must arrive as tool definitions in the prompt. A second commenter puts the same point at the protocol level — "The idea that MCP tool definitions take up a certain number of tokens is laughable. That's an implementation detail of the agent harness." — and a third calls deferral table stakes: "Most mature harnesses do some kind of tool search and/or progressive disclosure."

Both are right that token cost is a harness property. The reply is narrow: a harness property is exactly the thing this design refuses to depend on.

## Deferral is a host feature, and hosts disagree about it

That refusal is not theoretical. Every claim below is a filed, reproducible report:

| Reported | Client | Effect |
| --- | --- | --- |
| Deferred search does not index claude.ai-hosted MCP servers | Claude Code 2.1.114 | Tools show Connected in `/mcp`, `ToolSearch` returns zero results for them |
| The deferral threshold is computed from `model.default`, not the session model | hermes-agent | A 98,304-token local model gets a threshold sized for a 256K cloud model |
| Built-in server schemas load non-deferred with no opt-out | Claude Desktop | ~3.9k tokens of first-party schemas escape deferral every session |
| A configured tool deferred behind `tool_search` yields an empty turn | codex-cli 0.133.0 | `codex exec` completes with no assistant message |
| `notifications/tools/list_changed` ignored | Kiro IDE | New tools never appear until manual reconnection |
| Same notification ignored | GitHub Copilot CLI | Tool list never refreshes; VS Code updates immediately |

The last two matter for the next section: the MCP specification's answer to adding a capability at runtime is that servers "SHOULD send a notification", `notifications/tools/list_changed`. It is a SHOULD on the server and a silent no-op in at least two shipping clients.

## The 892nd capability costs one POST and no deploy

Not an argument — a round trip run for this page.

```
curl -X POST https://miscsubjects.com/api/directory \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  -d '{"key":"__DOC_PROBE","type":"http",
       "target":"GET https://miscsubjects.com/api/dispatch?key=TIME_NOW",
       "category":"docs","content":"# WHAT: Probe row created to time one capability insert.\n# ARGS: none\n"}'
```

Response, `HTTP 201` in **0.437946 s**:

```json
{"ok":true,"key":"__DOC_PROBE","updated_at":"2026-07-26T04:36:08.555Z"}
```

Row count went 891 → 892. No build, no deploy, no client restart, no reconnect. Thirty-five seconds later — the directory snapshot cache is a 30-second KV entry, set in `loadDirectory()` at `functions/api/dispatch.js:413-429` — the new capability had a full self-describing contract at `?key=__DOC_PROBE`, and invoking it returned:

```json
{"ok":true,"ran":true,"proof":{"ok":true,"did":"DONE — __DOC_PROBE",
 "invocation_id":"inv_z77vqe1qi6",
 "public_receipt":"https://miscsubjects.com/receipt/inv_z77vqe1qi6"}}
```

The receipt is still public: `GET https://miscsubjects.com/api/dispatch?confirm=inv_z77vqe1qi6` returns `"confirmed": true` with no credential. The probe row was then deleted (`DELETE /api/directory/__DOC_PROBE` → `{"ok":true,"deleted":1}`); the receipt survives the row, because receipts are append-only and rows are not.

The equivalent under definitions-in-context is: publish a new definition, emit `notifications/tools/list_changed`, and hope the client re-queries. Two of the clients above do not.

## Where this design loses

Stated plainly, because a page that argues one way is not worth reading.

- **It needs a running service.** The catalogue is a table behind a Worker. If `miscsubjects.com` is down, there are zero capabilities. An MCP server on stdio keeps working with no network.
- **There is no client-side discovery.** Nothing enumerates the catalogue into a UI, a permission prompt or a tool picker. MCP clients render tool lists, ask for consent per call, and show the user what the model can reach. The specification says implementations "SHOULD" keep a human in the loop; here the human-in-the-loop surface has to be built.
- **A model that cannot make HTTP calls cannot use any of it.** Every model behind this page can. That is an assumption, not a law.
- **There is no ecosystem.** No marketplace, no registry of third-party servers, no `npx` one-liner, no standard anyone else implements. Wrapping someone else's MCP server means writing rows.
- **Retrieval quality is worse than a purpose-built search.** Substring scoring plus a hand-pinned list, versus BM25 or regex with a vendor tuning it.
- **The resolver is a single point of failure for discovery.** If `?ask=` ranks wrong, the model does not know what it missed. A full definition list has no ranking to get wrong.
- **Round trips.** Discovery is a network call before the work. One commenter frames the whole MCP-versus-in-context debate this way — "I call this 'speed of light' as opposed to 'carrier pigeon'" — and the criticism lands here too: reading a contract costs a turn that an in-context definition does not.

## The month, multiplied out

At 200 model turns per day, one seat, the measured per-turn costs above:

| Exposure | Per turn | × 200 turns/day | × 30 days |
| --- | --- | --- | --- |
| Per-row MCP, definitions in context | $0.02852109 | $5.704218 | **$171.13** |
| Deferred tool search | $0.00443075 | $0.886150 | **$26.58** |
| No MCP server, capabilities over HTTP | $0.00456265 | $0.912530 | **$27.38** |

$171.13 − $27.38 = **$143.75 a month per seat**, for exactly the same reach. Between the two cheap rows the difference is $0.79 a month, which is not a reason to choose either. Choose row three when the capability layer has to outlive one vendor's tool-calling implementation; choose row two when the host already implements deferral and the catalogue is already MCP servers.

The full dimension-by-dimension comparison: [Tool Search and catalogue-as-data, compared](/a/tool-search-vs-catalogue-as-data). What the per-row MCP projection is for and when to attach it: [MCP as a projection, not a home](/a/mcp-as-a-projection).

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| `{"error":"unknown_key"}` from `?key=…` | The row is disabled, deleted, or the key is misspelled | `GET /api/dispatch?ask=<intent>` and use `best.key`; never guess a neighbouring key |
| A brand-new row 404s for up to 30 seconds | `loadDirectory()` caches the directory snapshot in KV for 30 s (`dispatch.js:413-429`) | Wait it out, or the write path calls `invalidateDirSnapshot(env)` (`functions/api/directory/index.js:73`) |
| `tools/list` returns fewer tools than the table has rows | `planner_visible=0` and `enabled=0` rows are excluded (`mcp.js:118-123`) | Correct behaviour. Do not edit the count to match the table |
| `POST /api/mcp` → `-32001 unauthorized` | The MCP projection takes `Authorization: Bearer <MCP_TOKEN>` or `x-mcp-token`, not the terminal key (`mcp.js:24-27`) | Send the MCP token |
| A capability runs but `proof.ok` is false | The runner returned no material output | Read the receipt and fire a repair: `{"key":"…","body":"corrected","repairs":"inv_ID"}` |
| `?ask=` returns the right row buried below GitHub rows | Two-letter query terms match as substrings (`object_contract.js:605-640`) | Query with a distinctive noun, or add the row to the canonical pin list |


## Sources

1. Tool search tool — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
2. Tool use with Claude — Pricing — https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
3. Tool search tool — context bloat and selection accuracy — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
4. Model Context Protocol specification 2025-11-25 — Server Features: Tools — https://modelcontextprotocol.io/specification/2025-11-25/server/tools
5. Model Context Protocol specification — User Interaction Model — https://modelcontextprotocol.io/specification/2025-11-25/server/tools
6. modelcontextprotocol/modelcontextprotocol — docs/specification/2025-11-25/server/tools.mdx — https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-11-25/server/tools.mdx
7. scalekit-inc/mcp-vs-cli-benchmark — runnable harness — https://github.com/scalekit-inc/mcp-vs-cli-benchmark
8. MCP vs CLI: Benchmarking AI Agent Cost & Reliability — https://www.scalekit.com/blog/mcp-vs-cli-use
9. [Feature] Lazy-load MCP tool definitions to reduce token overhead — https://github.com/anomalyco/opencode/issues/35376
10. `GCORE_TOOLS=*` advertises ~488k tokens of tool definitions — https://github.com/G-Core/gcore-mcp-server/issues/14
11. Local models via opencode: slim or defer MCP tool and skills injection — https://github.com/nimbalyst/nimbalyst/issues/914
12. Full plugin suite's MCP tool-schema overhead makes small-context backends unusable — https://github.com/ruvnet/ruflo/issues/2726
13. Comment on: Apideck CLI — lower context consumption than MCP — https://news.ycombinator.com/item?id=47400262
14. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48330912
15. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48336021
16. Comment on: MCP is dead? — https://news.ycombinator.com/item?id=48331540
17. Comment on: Zero-Touch OAuth for MCP — https://news.ycombinator.com/item?id=48594160
18. Comment on: Claude Advanced Tool Use — https://news.ycombinator.com/item?id=46039648
19. ToolSearch does not index tools from claude.ai-hosted MCP servers — https://github.com/anthropics/claude-code/issues/57033
20. tool_search auto-gate computes its threshold from model.default — https://github.com/NousResearch/hermes-agent/issues/57520
21. Desktop: ~3.9k tokens of built-in MCP tool schemas load non-deferred — https://github.com/anthropics/claude-code/issues/76372
22. codex exec can silently complete empty when configured MCP tools are deferred — https://github.com/openai/codex/issues/24536
23. Kiro IDE does not handle MCP notifications/tools/list_changed — https://github.com/kirodotdev/Kiro/issues/6553
24. GitHub Copilot CLI does not dynamically load tools via tools/list_changed — https://github.com/microsoft/wassette/issues/308
25. Comment on: Agent Skills — https://news.ycombinator.com/item?id=46878126
26. Comment on: When does MCP make sense vs CLI? — https://news.ycombinator.com/item?id=47209810
27. Comment on: MCP is dead; long live MCP — https://news.ycombinator.com/item?id=47381282
28. First-party: the same catalogue exposed three ways — https://miscsubjects.com/api/dispatch?map=1
29. First-party: four counts of the same table, taken live — https://miscsubjects.com/api/dispatch?registry=1
30. First-party: weighing the MCP projection with tools/list — https://miscsubjects.com/api/mcp
31. First-party: adding, invoking and removing a capability with no deploy — https://miscsubjects.com/api/dispatch?confirm=inv_z77vqe1qi6
32. First-party: the resolver's twelve results for one query — https://miscsubjects.com/api/dispatch?ask=what%20time%20is%20it
33. redacted/miscsubjects-architecture — docs/tooling/directory-row.md — https://github.com/redacted/miscsubjects-architecture/blob/main/docs/tooling/directory-row.md


---

# MCP definitions use 10.6× more input tokens until tool search defers them

slug: mcp-tool-search-cost · https://miscsubjects.com/a/mcp-tool-search-cost · tags: tooling, mcp, cost, tool-search, context-window, measurement · updated 2026-07-26T03:31:45.388Z

A connected MCP server costs nothing while its tools sit idle, and costs on every request, because the definitions travel with the request. **MCP** — Model Context Protocol — is the wire format an agent uses to discover and call tools on an external server. Its discovery call, `tools/list`, returns one object per tool: a name, a description, and a JSON Schema for the arguments. A client that keeps those objects in the request pays for all of them every turn, whether the model calls one or none.

## Evidence status

**Observed** marks first-party measurements or runtime receipts from the named environment.
**Derived** marks arithmetic calculated from cited inputs. **Specified** marks vendor or standards
documentation. **Implemented** and **deployed** name code and live-state evidence, respectively.
**Reproduced** means the stated procedure was rerun. **Externally attested** marks operator reports;
those reports show that an experience occurred, not that it is universal.

## A definition is a name, a sentence and a schema, and the schema is the part that grows

One live tool object, from `POST https://miscsubjects.com/api/mcp` with `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`:

```json
{
  "name": "CF_AI_GATEWAY_LIST_LOGS",
  "description": "List Logs MCP: https://ai-gateway.mcp.cloudflare.com/sse [fn · cf_ai_gateway]",
  "inputSchema": {
    "type": "object",
    "properties": {
      "gateway_id": { "type": "string", "description": "The gateway ID." },
      "page": { "default": 1, "type": "integer" },
      "per_page": { "default": 20, "type": "integer" },
      "order_by": { "default": "created_at", "type": "string",
        "enum": ["created_at","provider","model","model_type","success",
                 "cached","cost","tokens_in","tokens_out","duration","feedback"] },
      "order_by_direction": { "default": "desc", "type": "string", "enum": ["asc","desc"] },
      "start_date": { "type": "string" },
      "end_date": { "type": "string" },
      "feedback": { "type": "number" },
      "success": { "type": "boolean" },
      "cached": { "type": "boolean" },
      "model": { "type": "string" },
      "provider": { "type": "string" }
    },
    "required": ["gateway_id"]
  }
}
```

That object is **264 tokens** by the `o200k_base` tokenizer: 11 for the name, 22 for the description, 231 for argument names, types, defaults and one `enum` of eleven strings.

A description is written once by a human and stays a sentence. A schema grows with the API behind it — every optional filter, enum member and nested object — and nothing prunes it. The MCP specification requires it: `tools/list` returns `name`, optional `title`, `description` and `inputSchema` per tool, so there is no conforming way to publish a tool without publishing its argument surface.

Across the whole 831-tool catalogue on 2026-07-26, names cost 4,064 tokens, descriptions 54,596, schemas 54,465. Level in aggregate, which is not the folklore. Split by size and the folklore returns:

| Slice of the catalogue | Tools | Tokens | Share that is schema |
| --- | --- | --- | --- |
| Definitions under 300 tokens | 800 | 109,856 | 39% |
| Definitions 300 tokens or more | 31 | 17,584 | 67% |
| The 20 most expensive definitions | 20 | 13,963 | 75% |
| The single most expensive, `CF_OBSERVABILITY_QUERY_WORKER_OBSERVABILITY` | 1 | 1,803 | 95% |

Cheap tools are mostly prose; expensive tools are almost entirely schema. A catalogue's bill is set by its handful of query-shaped tools, not its median row. An independent measurement on a different server found the same tail: G-Core's MCP server at `GCORE_TOOLS=*` advertises 741 tools for about 488,013 tokens, an average of 659 per tool, one tool alone at about 7,046 tokens of schema.

## Measure it three ways, cheapest first

**1. `/context`, in a running session.** Breaks the window into system prompt, system tools, MCP tools, memory, skills and messages. Free, one second. Read the contradiction section before trusting a zero.

**2. The `usage` field of any API response.** Every Messages API response carries `usage.input_tokens`, `usage.output_tokens` and the cache counters. Send the same one-word prompt twice, with the servers attached and without, and subtract. The only method that measures what you are billed for.

**3. A capture server, for what the client puts on the wire.** A local HTTP server that speaks enough of the Messages API to answer, and logs every request:

```bash
git clone https://github.com/redacted/claude-code-cloudflare-gateway
cd claude-code-cloudflare-gateway
node tools/capture-gateway.mjs      # listens on :8787, appends capture.jsonl
```

It records `n_tools`, `tool_names`, `system_chars`, `system_cache_control`, `metadata` and the model per request, redacting `authorization` and `x-api-key`. Point the client at it twice, changing one variable:

```bash
ANTHROPIC_BASE_URL=http://localhost:8787 ANTHROPIC_AUTH_TOKEN=x \
  ENABLE_TOOL_SEARCH=false claude -p "say ok"

ANTHROPIC_BASE_URL=http://localhost:8787 ANTHROPIC_AUTH_TOKEN=x \
  ENABLE_TOOL_SEARCH=true claude -p "say ok"
```

Read the two tool counts out of the log:

```bash
python3 -c "
import json
for line in open('capture.jsonl'):
    r = json.loads(line)
    if r.get('n_tools') is not None:
        print(r['model'], 'tools=', r['n_tools'])
"
```

Two lines. On `claude-cli 2.1.165` against that server on 2026-07-25: `tools= 856` and `tools= 9` — the nine being `Agent`, `AskUserQuestion`, `Bash`, `Edit`, `Read`, `Skill`, `ToolSearch`, `Workflow`, `Write`.

**To price a catalogue without running the client**, count the tokens the server publishes. The command behind the 831-tool figures above, good against any HTTP MCP server that answers `tools/list`:

```bash
curl -s -X POST https://miscsubjects.com/api/mcp \
  -H "Authorization: Bearer $MCP_TOKEN" -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' -o tools.json

python3 -m venv /tmp/tokvenv && /tmp/tokvenv/bin/pip install tiktoken
/tmp/tokvenv/bin/python -c "
import json, tiktoken
enc = tiktoken.get_encoding('o200k_base')
t = json.load(open('tools.json'))['result']['tools']
tot = sum(len(enc.encode(json.dumps(x))) for x in t)
print('tools', len(t), 'tokens', tot, 'mean', round(tot/len(t), 1))
"
```

Output on 2026-07-26: `tools 831 tokens 127440 mean 153.4`, from a 434,636-byte response. `o200k_base` is OpenAI's tokenizer, not Anthropic's — treat it as a close estimate and billed `usage` as the truth. They agree to about 12% here: 149,187 billed input tokens for 856 definitions plus a system prompt and a short user message is 174.3 tokens per definition against the tokenizer's 153.4.

## Three configurations, same catalogue, same day

Same machine, same catalogue of 891 capabilities, same trivial prompt, `@cf/moonshotai/kimi-k2.7-code` through a Cloudflare AI Gateway, 2026-07-25. Tokens and cost read from the gateway's own log rows.

| Configuration | Tool definitions in the request | Input tokens | Cost per turn |
| --- | --- | --- | --- |
| Every directory row projected as an MCP tool, definitions in context | 856 | 149,187 | $0.02852109 |
| Same catalogue, `ENABLE_TOOL_SEARCH=true` | 9, one of them `ToolSearch` | 14,109 | $0.00443075 |
| No MCP server attached, the same 891 capabilities reached over HTTP | 9 built-in tools | 14,071 | $0.00456265 |

149,187 input tokens is 74.6% of a 200,000-token window, spent before the user's sentence is read. Deferring the definitions cuts that 10.6-fold, 135,078 tokens a turn.

The third row decides architecture: **tool search on is as cheap as having no MCP server at all** — 14,109 against 14,071, a 0.3% difference — with every tool still reachable. The tools were never the cost. The definitions were.

The dollar column is the gateway's own accounting and is an estimate; the token counts are measured at both ends and the argument rests on them.

That 14,071-token row is still readable. Fetched 2026-07-26 from the AI Gateway logs REST endpoint (`GET /accounts/<account_id>/ai-gateway/gateways/default/logs?per_page=3&order_by=created_at&order_by_direction=desc`):

```json
{"created_at":"2026-07-26T03:43:18.667Z","model":"@cf/moonshotai/kimi-k2.7-code",
 "metadata":{"via":"claude-code","shim":"api/aig","tools":9},
 "tokens_in":14071,"tokens_out":170,"cost":0.00456265,
 "usage_metadata":{"input_cached_tokens":12480}}
```

`metadata.tools` is what the client sent: nine, not 856, with the same catalogue attached.

## The attribution line was worth more than 12,000 cached tokens a turn

Claude Code prepends an attribution block — client version and prompt fingerprint — to the start of the system prompt. A prompt cache keys on an exact prefix, so a value that changes per request, at the front, means the prefix never matches and nothing before it is ever a hit. Anthropic's environment-variable reference: set `CLAUDE_CODE_ATTRIBUTION_HEADER` to `0` to omit the block, "Disabling it improves prompt-cache hit rates when routing through an LLM gateway. Caching on a direct connection to the Anthropic API is unaffected either way."

Measured on the rows above, cached input went from **64 tokens to 12,480 tokens** per turn once the line was dropped — 88% of a 14,109-token turn arriving from cache instead of priced as fresh.

```bash
export CLAUDE_CODE_ATTRIBUTION_HEADER=0
```

Anthropic's gateway protocol reference adds the version detail and the right place to fix it: from v2.1.181 the block is stable for the lifetime of a conversation behind a custom base URL, and "If your gateway must reshape system content, set `CLAUDE_CODE_ATTRIBUTION_HEADER=0` so Claude Code omits the block… omit it at the client rather than stripping or moving it in the gateway." Stripping it gateway-side breaks attribution downstream; omitting it at the client does not.

## Deferred tool search: names stay, schemas arrive on request

Anthropic's documentation describes the mechanism: "When tool search is active, tool definitions are withheld from the context window. The agent receives a summary of available tools and searches for relevant ones when the task requires a capability not already loaded. Up to five of the most relevant tools are loaded into context by default." A discovered tool stays available for later turns; if compaction removes it, the model searches again.

The switch:

```bash
export ENABLE_TOOL_SEARCH=true
```

Permanently, in `~/.claude/settings.json`:

```json
{ "env": { "ENABLE_TOOL_SEARCH": "true", "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" } }
```

Every value the variable accepts:

| Value | Behaviour |
| --- | --- |
| unset | Deferred by default — but **loaded upfront** when `ANTHROPIC_BASE_URL` points at a non-first-party host, or on Google Cloud's Agent Platform |
| `true` | Always defer, and send the beta header even through a proxy. Requests fail on proxies that do not support `tool_reference` blocks |
| `auto` | Load upfront if the definitions fit within 10% of the context window, defer the overflow |
| `auto:N` | Same with a custom percentage, e.g. `auto:5` |
| `false` | Load every definition upfront, every turn |

Two costs come with it, both small. The search tool's own definition stays in the request — it is one of the nine measured above, and Anthropic's API reference is explicit that "At least one tool, normally the tool search tool itself, must stay non-deferred." And the first use of an unseen tool costs an extra round-trip; below roughly ten tools, loading everything upfront is faster.

The API-side mechanism differs from the client-side one in a way that matters if you are building a gateway. With `defer_loading: true` on the Messages API, "You still send every tool's full definition in the `tools` array on every request, including the deferred ones. The API needs them server-side to run the search." The saving there is context, not bytes. The capture above shows the client doing the other thing — sending nine definitions — because behind a non-first-party base URL Claude Code resolves the search itself. Both are called tool search. Only one shrinks what leaves your machine.

## Five ways the definitions stay in the bill anyway

| What still costs | Where it was observed | The report |
| --- | --- | --- |
| First-party servers exempt from deferral | Claude Desktop, `anthropics/claude-code` issue 76372, 2026-07-10 | With tool search active, third-party MCP tools defer to names only, but "three Desktop built-in servers load complete schemas upfront every session" — about 3,900 tokens with no opt-out, isolated by reading `message.usage` from session JSONL across 6 sessions |
| Servers invisible to the search index | Claude Code 2.1.114, `anthropics/claude-code` issue 57033, 2026-05-07 | Servers added at claude.ai/settings/connectors show Connected in `/mcp`, yet "Any `ToolSearch` query that should match a claude.ai MCP tool returns zero results". Local `.mcp.json` and plugin servers index fine |
| Threshold computed from the wrong model | `hermes-agent` issue 57520, 2026-07-03 | The auto-gate reads the configured default model, not the session model, so "the gate is scaled to the wrong window" — a 98,304-token local model gets a 25,600-token threshold derived from a 256K cloud model instead of 9,830, and takes the whole payload inline |
| A turn that silently completes empty | codex-cli 0.133.0, `openai/codex` issue 24536, 2026-05-26 | "`codex exec` can silently finish with no assistant message when an explicitly configured MCP tool is deferred behind `tool_search`" — the server stays healthy and registered but is only reachable through deferral, and the harness accepts the empty turn |
| Lazy loading invalidating the prompt cache | Hacker News, 2026-03-01 | "The main problem with this approach at the moment is it busts your prompt cache, because LLMs expect all tool definitions to be defined at the beginning of the context window" |

The cache row cuts the other way for the deferred case. Anthropic's caching page puts tool definitions in the system-prompt layer, so the cache invalidates when the set of definitions changes between turns — but with deferred tools, "a server connecting, disconnecting, or changing its tool list only appends new content and doesn't disturb anything already cached", while with tools in the prefix "any change to them invalidates the cache". A gateway is named as one place where deferral is off and the prefix is therefore fragile: a stdio process exiting, an HTTP session expiring or an automatic reconnect invalidates the whole cached prefix with nobody touching a keyboard.

An operator who ran it and was not convinced, on Hacker News on 2026-03-15: "And no, the tool search function recently introduced by Anthropic does not completely solve this problem."

## One harness reports zero tokens per tool. Others publish tables of tens of thousands

Zero, filed against Claude Code as issue 23228 on 2026-02-05: "When running the `/context` command to check token usage, the output includes a long list of all MCP tool definitions (28 in my case), each showing '0 tokens'."

Not zero, filed on 2026-03-01 with a per-server breakdown: "The main agent context burns 16.9k tokens (8.5% of 200k) loading 68 MCP tool schemas at session start. Most are never called by the main agent — they're used by subagents." That author's table attributes 6.0k to one server's 25 tools, 3.3k to another's 14. Two more, independently: 741 tools at about 488,013 tokens on a G-Core server, and 250-plus definitions across 9 servers at "~40,000-70,000 tokens of tool definitions loaded upfront".

Both can be true, and the mechanism is documented. `/context` reports what is **in the context window**, category by category. Deferred definitions are by construction not in the window — Anthropic's cost guidance: "MCP tool definitions are deferred by default, so only tool names enter context until Claude uses a specific tool." A per-tool row of `0 tokens` is then accurate, and the residual cost lands under another heading. The tables of tens of thousands come from sessions where the definitions were not deferred — an older client, a `false` setting, a non-first-party base URL, `alwaysLoad`, an exempt built-in server — and from harnesses that count the catalogue rather than ask the client.

Which to trust for a decision: **neither, over the `usage` field of your own responses.** `/context` reports placement, not billing, and its zero is silent about the 3,900 tokens of exempt built-in schemas in issue 76372. A tokenizer table reports the size of a catalogue, not what your client chose to send — the capture above shows a client sending 9 of 856. Only `usage.input_tokens`, differenced across two otherwise identical runs, answers the question being asked.

One dissent earns its place, from Hacker News on 2026-05-30: "The idea that MCP tool definitions take up a certain number of tokens is laughable. That's an implementation detail of the agent harness." Correct about the protocol, beside the point about the invoice: the protocol mandates no loading strategy, and the client you are running has already picked one and is billing you for it.

## The arithmetic at 200 turns a day

200 model turns in a working day, 30 days in a month. Substitute your own turn count; the multiplication is the same.

| Configuration | Cost per turn | × 200 turns = per day | × 30 days = per month |
| --- | --- | --- | --- |
| 856 definitions in every request | $0.02852109 | $5.70 | $171.13 |
| `ENABLE_TOOL_SEARCH=true` | $0.00443075 | $0.89 | $26.58 |
| No MCP server, capabilities over HTTP | $0.00456265 | $0.91 | $27.38 |

One environment variable is worth $4.82 a day and **$144.54 a month** on one machine, one catalogue, these rates. In tokens, which do not depend on anyone's pricing: 135,078 fewer input tokens per turn, 27,015,600 fewer per day.

Two effects the table does not price. The definitions occupied 74.6% of a 200,000-token window, so the ceiling on what a session can hold moves further than the invoice does. And tool-selection accuracy is documented to degrade "with more than 30-50 tools loaded at once" — a quality cost with no line item.

## Five responses, ranked by what they cost you to adopt

**1. Turn tool search on.** One variable, no code, every tool still reachable, measured 10.6× fewer input tokens. Trade-off: an extra round-trip on first use of an unseen tool; unavailable behind a base URL that cannot forward `tool_reference` blocks; below ten tools it is slower than loading them.

**2. Scope servers per project.** Enable a server only in the repository that needs it. An operator on Hacker News, 2026-07-12: "I enable tools specific to each project only in that project, and have very very few in my global config. Like <5k tokens worth." Trade-off: manual, and it fails the way manual hygiene always fails — the day you forget.

**3. Collapse many tools into few.** Replace N tools with one or two that take a name and arguments and dispatch internally. From the operator who built it: "This basically takes your APIs, databases, and docs and compresses them into 2 MCP tools (~1,000 tokens) instead of N tools (100K+ tokens)." A minimal version of the same idea, reported at about 60 tokens: one bare tool with a one-line description that unlocks the rest when called. Trade-off: you have rebuilt tool search without the harness's search quality, and the model must learn your dispatch convention.

**4. Take the catalogue out of the prompt entirely.** Publish capabilities behind an HTTP contract the model reads on demand, keep only the built-in tools. Measured at 14,071 input tokens with 891 capabilities reachable — the same number as tool search, a different structure, because catalogue size is no longer a term in the per-turn equation and no host support is required. Trade-off: your capabilities are not MCP tools, so anything that consumes MCP does not see them. Dimension by dimension in [Tool search versus a catalogue as data](/a/tool-search-vs-catalogue-as-data), the data layer in [Tooling as data](/a/tooling-as-data), the three exposures side by side in [MCP as a projection](/a/mcp-as-a-projection).

**5. Do nothing.** Defensible at a small tool count: under about ten tools, upfront loading is faster and the search round-trip is pure overhead. At 150–200 tools, two reports show the limit: one one-word prompt produced a request with "`prompt_tokens: 154,367`"; in the other, roughly 200 tool schemas exceeded a 32k window so completely that "`/compact` succeeded in producing a summary, but the very next request still failed."

## Symptom, cause, fix

| Symptom | Cause | Fix |
| --- | --- | --- |
| Input tokens in the tens of thousands before your first word | Every definition in the prefix, every turn | `ENABLE_TOOL_SEARCH=true` |
| Setting it changed nothing, tools still all loaded | `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` is set — it "keeps tool search off, and `ENABLE_TOOL_SEARCH` can't override it" | Unset it, or accept upfront loading |
| Request fails after enabling it, behind a proxy | The proxy does not forward `tool_reference` blocks; errors of the form "Unexpected value(s) for the `anthropic-beta` header" or "Extra inputs are not permitted" | Fix the proxy to pass the beta header through, or set `ENABLE_TOOL_SEARCH=false` |
| Deferred by default on a direct connection, upfront through a gateway | Deferral is off by default "when `ANTHROPIC_BASE_URL` points to a non-first-party host" | Set `ENABLE_TOOL_SEARCH=true` explicitly |
| Tool search finds nothing for a server that `/mcp` shows Connected | claude.ai-hosted connectors are not in the search index (issue 57033) | Add the server via local `.mcp.json` instead |
| A scripted run ends with no assistant message | The required tool was deferred and the harness accepted an empty turn (codex issue 24536) | Mark that server `alwaysLoad`, or pin it non-deferred |
| One server must never need a search step | Deferral applies to every server by default | `"alwaysLoad": true` on that server in `.mcp.json` — every one of its tools then loads at session start regardless of `ENABLE_TOOL_SEARCH` |
| Cached input near zero behind a gateway | The attribution block changes the prefix per request | `CLAUDE_CODE_ATTRIBUTION_HEADER=0` at the client, never stripped in the gateway |
| Cache misses mid-session for no visible reason | A server connected, disconnected or pushed a tool-list change while definitions sat in the prefix | Defer the tools, so a server change "only appends new content" |
| `/context` shows every MCP tool at 0 tokens | Deferred definitions are not in the window; `/context` reports placement, not billing | Difference `usage.input_tokens` across two runs instead |
| Tool search on and the bill barely moved | Built-in or `alwaysLoad` servers are exempt (issue 76372, about 3,900 tokens) | Audit with a capture server; `n_tools` in the log is the ground truth |

The gateway that produced these log rows, and how the same client runs on a non-Anthropic model, is documented in [Claude Code on Kimi, GLM or Grok through your own Cloudflare account](/a/claude-code-on-cloudflare-ai-gateway).


## Sources

1. Claude Code environment variables — ENABLE_TOOL_SEARCH and CLAUDE_CODE_ATTRIBUTION_HEADER — https://code.claude.com/docs/en/env-vars
2. Connect Claude Code to tools via MCP — scale with MCP tool search — https://code.claude.com/docs/en/mcp
3. How Claude Code uses prompt caching — what invalidates a cached prefix — https://code.claude.com/docs/en/prompt-caching
4. Gateway protocol reference — where to disable the attribution block — https://code.claude.com/docs/en/llm-gateway-protocol
5. Manage costs effectively — MCP tool definitions are deferred by default — https://code.claude.com/docs/en/costs
6. Debug your configuration — what /context actually reports — https://code.claude.com/docs/en/debug-your-config
7. Scale to many tools with tool search — the mechanism and its break-even point — https://code.claude.com/docs/en/agent-sdk/tool-search
8. Model Context Protocol specification — tools/list — https://modelcontextprotocol.io/specification/2025-06-18/server/tools
9. Tool search tool — defer_loading controls context, not the request body — https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool
10. capture-gateway.mjs — the logging server behind these wire measurements — https://github.com/redacted/claude-code-cloudflare-gateway
11. tiktoken harness against a live MCP server: 741 tools, ~488,013 tokens — https://github.com/G-Core/gcore-mcp-server/issues/14
12. /context shows 28 MCP tool definitions, each at 0 tokens — https://github.com/anthropics/claude-code/issues/23228
13. 16.9k tokens on 68 MCP tool schemas, broken down per server — https://github.com/yonatangross/orchestkit/issues/885
14. 9 MCP servers, 250+ definitions, 40-70k tokens on every message — https://github.com/anomalyco/opencode/issues/35376
15. Built-in servers load full schemas despite tool search — ~3.9k tokens, no opt-out — https://github.com/anthropics/claude-code/issues/76372
16. ToolSearch does not index claude.ai-hosted servers — https://github.com/anthropics/claude-code/issues/57033
17. A deferred tool can end a scripted run with no assistant message — https://github.com/openai/codex/issues/24536
18. The deferral threshold is computed from the wrong model — https://github.com/NousResearch/hermes-agent/issues/57520
19. Loading tools lazily busts the prompt cache — https://news.ycombinator.com/item?id=47209810
20. A first-hand verdict: tool search does not completely solve it — https://news.ycombinator.com/item?id=47392361
21. The dissent: token cost is a harness detail, not a protocol property — https://news.ycombinator.com/item?id=48331540
22. The positive report: deferral retired the context-bloat complaint — https://news.ycombinator.com/item?id=48332962
23. Per-project scoping keeps a global tool surface under 5k tokens — https://news.ycombinator.com/item?id=48885036
24. Collapsing an API into two MCP tools: 100K+ tokens down to ~1,000 — https://news.ycombinator.com/item?id=47614267
25. A hand-rolled gate tool at about 60 tokens — https://news.ycombinator.com/item?id=47719249
26. A unified-API vendor measured 50,000+ tokens before the first user message — https://news.ycombinator.com/item?id=47400262
27. One popular server named at about 50k tokens — https://news.ycombinator.com/item?id=45955033
28. A one-word prompt that cost 154,367 input tokens — https://github.com/nimbalyst/nimbalyst/issues/914
29. Schema overhead that /compact cannot recover — https://github.com/ruvnet/ruflo/issues/2726
30. First-party: 831 published tool definitions, 127,440 tokens — https://miscsubjects.com/a/mcp-tool-search-cost
31. First-party: one real definition, 264 tokens, and where the tokens sit — https://miscsubjects.com/a/mcp-tool-search-cost
32. First-party: a gateway log row, nine tools, 14,071 input tokens — https://developers.cloudflare.com/ai-gateway/observability/logging/
33. First-party: 856 definitions become 9 on the wire — https://github.com/redacted/claude-code-cloudflare-gateway
34. First-party: three configurations priced from gateway log rows — https://miscsubjects.com/a/mcp-tool-search-cost

