21STARK
All posts
12 min read views

One MCP tool to rule 466 (534 by the time I hit publish)

Context rent: every mounted tool schema charges you on every turn, called or not. My admin plane fronts a drifting capability catalog with one read-only ask tool and makes a disposable sub-agent pay the discovery bill.

466 capabilities. That was the count on June 30, the day I wrote the spec. Eighteen days later, the generated catalog at HEAD says 534, across 31 connectors. Rebuild the registry with every env-gated and nil-guarded connector unlocked and it knows 672 unique capability names across 40 connectors. The number refused to sit still long enough for me to write this post.

Good. That drift is half the argument.

The other half: my main Claude Code session fronts all of it with one tool.

The surface

tyr is my internal admin plane. A Go monorepo: 265K lines of non-test code, 207K lines of tests, 16 binaries. Connectors for Google Workspace (64 capabilities), PagerDuty (53), Twingate (52), GA4 (45), Notion (38), Atlassian (36), ClickUp (32), GitHub (29). The spec's exact words: "Collapse stark-admin's 466-capability surface into one MCP tool."

Honest scoping first. The deployed backend the agent talks to serves 261 capabilities across 19 connectors, and a deploy gate diffs the live inventory against a committed expected-caps.txt on every release. The rest of the library stays CLI and local-serve only, on purpose. The agent rules the deployed slice, not the whole warehouse.

The naive MCP integration is obvious: one tool per capability. Everyone's first instinct. Here is what it costs.

The full capability catalog, with schemas, is 635 KB of JSON. At a naive bytes/4, that is roughly 159K tokens. Input schemas alone: 163 KB. Descriptions: another 82 KB. Mount that as first-class tools in a classic MCP client and the session is dead before your first message. Some harnesses now defer tool schemas and search them on demand. That softens the blow. It does not change the math, it moves who pays it, and deferral bit me hard anyway (story below).

And the surface degrades before it dies. Hundreds of tools compete for the model's attention on every selection. Vendor MCP servers with concrete tool names win the ambiguous cases. Your generic surface loses.

I have a name for this cost. Context rent. Every tool schema you mount charges rent on every turn of every session, called or not. The question is never "is this tool useful". It is "is this tool worth the rent".

The mechanism: collapse twice

tyr's own MCP server already refuses to pay per-capability rent. It exposes 3 meta-tools: list_capabilities, describe_capability, invoke_capability. Plus 20 curated named tools (hibob_whos_out, jira_search, ga4_run_report and friends). Those 20 exist for one reason: a generic dispatcher loses the tool-name selection battle to vendor tools with concrete names. High-traffic paths keep concrete names. Everything else routes through the meta-tools.

The flow is just-in-time. list_capabilities returns compact summaries only: name, connector, one-line description, a mutating flag. describe_capability loads exactly one full schema, right before invoke, and the primer says to bother only "when you are unsure of the args shape". Rent becomes pay-per-visit.

That was collapse one: 534 capabilities down to 23 tools. Collapse two takes it to one.

stark-agent is a small TypeScript MCP server: 1,371 lines of source, 892 lines of tests, 77 unit tests, 7 commits, built between June 30 and July 11. It exposes one working tool, ask. (Two, honestly. There is also a doctor preflight that checks PATH, credentials, and the backend. The work goes through ask.)

Each ask spins up a Claude Agent SDK query() loop. That sub-agent's only toolset is tyr's MCP, mounted through an in-process proxy:

Claude Code
  -> ask({ task, model?, allowed_connectors?, budget_usd?, max_turns? })
     -> supervisor (concurrency 2, 180s abort)
     -> pre-spend budget gate
     -> Agent SDK query()
          mcpServers: { "stark-admin": in-process scoped proxy }
            -> proxy owns the real MCP client, spawns `tyr-mcp stdio`
               -> HTTP + id-token -> stark-admin-serve (Cloud Run)
                  -> registry dispatch

The whole design fits in one README sentence: "The expensive discovery turns happen in the sub-agent's context, not your main session's." The sub-agent runs the list, describe, invoke dance in a disposable context. My session pays for a task string going in and an answer coming out. Once you trust it, you scope down or disable the raw tyr MCP and the thin vendor MCPs. That is where the main-session savings actually land.

Claymation robot carrying a large glowing index card through a bright archive hall of pastel filing cabinets, handing it toward a human hand reaching through a round hatch in the wall.

Read-only is a structure, not a promise

The sub-agent is pinned read-only at the SDK level:

// stark-agent, src/agent.ts (buildQueryOptions)
tools: [],                 // disables ALL built-in tools
strictMcpConfig: true,     // ignores ambient .mcp.json / settings / plugins
disallowedTools: ["Bash", "Read", "Write", "Edit", "NotebookEdit", "WebFetch",
  "WebSearch", "Glob", "Grep", "Task", "KillShell", "TodoWrite"],
allowedTools: ["mcp__stark-admin__*"],  // auto-approve only, NOT a restriction
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,

Read the allowedTools line again. An allow-list is an auto-approve list, not a fence. It cannot enforce anything. If you rely on it as a restriction, you have no restriction. The three fields above it do the enforcement: no built-in tools, no ambient MCP config, an explicit deny list. A unit test locks this posture without touching the live SDK.

That is lock one. Lock two lives server-side, where I do not have to trust the agent at all:

// tyr, serve/server.go
if cap, ok := s.reg.Lookup(req.Name); ok && cap.Mutating && !req.AllowMutating {
    WriteEnvelope(w, rid, NewError("CapabilityForbidden",
        "mutating capability requires explicit confirmation", …))

And the part I actually like: the MCP backend interface is Invoke(ctx, name, args). There is no confirm parameter. Only the local CLI's --confirm path can set allow_mutating, and writes still sit behind per-connector env gates. 13 of the 261 deployed capabilities are mutating. All 13 are structurally unreachable from any agent, on any prompt, in any mood. Not "please don't". Can't.

Minimal line drawing of a large circular bank vault door with no handle, dial, or keyhole, next to a small orange velvet rope barrier on two brass posts.

Hard scope, not polite scope

allowed_connectors started life as prompt text. v1 wrote the scope into the primer and flagged violations after the fact. Advisory, in other words. Worthless under pressure.

The red team said so. I ran the design docs through a gpt-5.5-pro review: 153 seconds, 6 findings, 4 blocking. Finding one: read-only prevents mutation, not sensitive aggregation. One ask could sweep HiBob, GitHub, and GCP into a tidy org-reconnaissance report. Read-only exfiltration is still exfiltration.

So scope became structure too, one day after v1. The sub-agent no longer mounts the real tyr MCP. It mounts an in-process proxy with the same three tool names. The proxy owns the real client and checks scope before forwarding:

  • Out-of-scope describe or invoke: refused, never forwarded. No schema leak. The refusal teaches instead of stonewalling: refused: "gh.org_members_list" is outside this call's allowed connector scope (hibob). This is a hard boundary, then it tells the model to pick a capability under an allowed connector or report that the task needs an out-of-scope one.
  • Scoped list: one upstream call per allowed connector, merged. Out-of-scope capabilities are never even listed:
// stark-agent, src/proxy.ts
if (!allowed || allowed.length === 0) return upstream.callTool("list_capabilities", args);
const results = await Promise.all(
  allowed.map(c => upstream.callTool("list_capabilities", { ...args, connector: c }))
);
return mergeScopedList(results);

Enforcement is pure functions tested against a fake upstream, plus one live smoke test: scope an ask to hibob, hand it a task that explicitly baits it into listing GitHub org members, and watch the gh invoke never succeed. Belt and suspenders: the ask handler still flags any out-of-scope invoke in the trace and strips its payload from the returned data.

The red team also demanded a pre-spend budget breaker and got one. The floor estimate is deliberately conservative: 4,000 input plus 800 output tokens per turn, times max_turns, times pinned model rates. Floor above budget_usd (default $1), or above the $10/hour rolling window: the call refuses with BudgetExceeded and zero tokens spent. A 10-second handshake deadline does the same for a dead backend, BackendUnavailable before any spend. Opus stays the default model. The red team suggested Sonnet-by-default as spend defense; I rejected it. The breaker is the defense. A quieter model is just a quieter bill.

Real numbers from the redacted audit log (one JSONL line per ask, keys and types only, never values), all on Sonnet: who's out plus their open Jira tickets, 22 turns, $0.29. Top GCP projects by spend, 18 turns, $0.18. GitHub org members without Cursor seats, 8 turns, $0.52. That is the going rate for a cross-system admin question answered end to end.

What broke

Three stories. All real, all from the first two weeks.

The zero-tools turn. Same day as v1. tools: [] disables the built-in tools, but it also removed the SDK's tool-search, and the mounted MCP's tools load deferred. Net effect: on turn 1 the sub-agent saw zero tools. It answered anyway. Confidently. With hallucinated capability names. The audit log still shows the wreckage: the first two asks ever recorded read outcome:ok, turns:1, caps:[]. An "ok" with no capability calls is the scariest line in that file. Fix: alwaysLoad: true on the mounted server. If your harness defers tools, verify the sub-agent can actually see them on turn one.

Comic-style grinning robot in a sunlit workshop proudly holding open a metal toolbox that contains only a few loose screws and bolts, no tools.

The 403 that wasn't authz. The MCP SDK does not inherit your environment. The stdio transport spawns children with a minimal safe-list (HOME, PATH, SHELL and friends); anything you pass in env merges over that list, nothing more. The spawned tyr-mcp lost its impersonation config and fell back to the gcloud user account, and gcloud mints an id-token whose audience is gcloud's own OAuth client, not the Cloud Run URL. The backend 403'd a user who held run.invoker. The failure was audience, and only service-account impersonation can set a custom one. Fix: forward the operator env explicitly, then harden doctor to fail loudly with a critical NO_IMPERSONATION_SA check instead of letting an ask 403 mid-flight. When a 403 makes no sense, check what the token says about its audience before you touch IAM.

The phantom resource. The primer hinted the sub-agent should read stark-admin:// resource URIs. The deployed serve backend exposed no resources at the time. Every single ask burned a turn on a read that could not succeed. The primer now leads with list_capabilities and says, in writing, not to try those URIs. Lesson: a primer is code on the hot path. A wasted turn in it taxes every call. Review it like code.

Context rent, the rule

Now the concept, properly.

Every tool schema you mount charges context rent: you pay in every session, on every turn, whether the tool is called or not.

Once you see tooling as rent, the design falls out by itself:

  • JIT the schema. A schema belongs in context for the turn it is needed, not for the lifetime of the session. Compact list, describe one, invoke. Pay per visit.
  • Move the tenancy. When discovery is genuinely expensive, let a disposable sub-agent context pay it. The main session mounts a router, not a roster.
  • Pay full rent only for winners. tyr keeps 20 named tools because concrete names win tool selection where a generic dispatcher loses. That is rent paid consciously, line by line.

Rent also explains the drift problem. 466 to 534 in eighteen days. Even the 466 needs a footnote: the generated catalog on July 1 held 391 entries, so the spec's number almost certainly counted gated connectors the catalog omits, and exact reconstruction from git is impossible. Counts lie the moment you write them down. The repo's own strings cannot keep up either: the served MCP instructions say "18 connectors", the comment above them says 13, and the Connectors variable holds 23 entries. The project name moved under me mid-build too (stark-admin became tyr on July 11, 329 environment variables renamed; the Cloud Run service keeps the old name, and the sub-agent's mount is still keyed "stark-admin" at HEAD).

A static tool list rots at that rate. A router does not care. A new connector lands on the backend, list_capabilities returns it, done. Nobody edits a tool manifest. Nobody re-mounts anything.

Take this

  • Measure your rent. Sum the schema tokens you mount versus the tools you actually call per session. The ratio will embarrass you.
  • Enforcement is structural or it is decoration. Prompt text is advisory. Allow-lists are auto-approve. The fence is whatever removes the capability, and the best confirm flag is the one the transport cannot express.
  • Refusals should teach. A hard boundary plus "do this instead" keeps the loop moving. A bare refusal makes the model thrash.
  • Red-team the design doc, not just the code. 153 seconds bought me four blocking findings and three of my best mechanisms: the scoped proxy, the budget breaker, the versioned data envelope. Refusing at $0 beats alerting at $40.

The count will keep moving

The catalog gained 68 capabilities in eighteen days. A new localdb source landed the day before I froze these numbers; the binary on my machine still says 533. I don't care, and that is the point. Every new capability arrives with keyfile auth, host allowlists, rate limiters, kill switches, and typed errors already attached, and my session's tool list does not grow by a single entry.

Somewhere in your stack there is a tool list growing one schema at a time. It will not stop growing. The rent only goes up.

Own the building instead.

Get in touch

I write about AI-first engineering on LinkedIn. Specs in, production out, nobody types code. Follow along there, or send a note.

hi@21stark.com · LinkedIn opens my profile, message me from there


Or send it from here