Every tool you connect to an agent is two things at once: tokens the model reads on every run, and an action the model can take. So the same inventory answers your cost question and your permission question. I start every agent with that inventory, and with nothing connected that the task doesn't need.
I talked through this with Tom Smith on CoderLegion's Developer Stories (video, from 9:46). This post is the practical version.
Why the inventory matters
An MCP client discovers tools by sending tools/list to each server. Each tool comes back with a name, a description and a JSON Schema for its input, and the client typically hands those definitions to the model. Anthropic published one example of what that costs: five servers (GitHub, Slack, Sentry, Grafana, Splunk) exposing 58 tools took about 55K tokens "before the conversation even starts" (Anthropic engineering). The same post names wrong tool selection as the most common failure when tools have similar names.
That is the cost side. The permission side is simpler: if a tool is in the list, the model can choose it.
The three controls I use
1. A sandbox per agent, deny by default
Each agent gets its own environment with only the access we grant. Nothing is inherited from the developer's machine or from another agent. Zero trust applies inside the system, not only at its edge.
2. A per-task tool allowlist
Instead of connecting every server the team uses, the task declares what it needs. An illustrative policy, not a format any tool enforces today:
# Illustrative per-task policy. Proposed design, not an implemented control.
task: triage-failing-build
allow:
- server: github
tools: [get_pull_request, list_check_runs, get_job_logs]
- server: sentry
tools: [get_issue]
deny_everything_else: true
run_budget:
max_tool_calls: 40
max_total_tokens: 200000
wall_clock_timeout: 10m
on_budget_exceeded: stop_and_report
Two things to notice. Write tools (create_comment, merge) aren't in the list, so this task can read and report but not act. And the budget is part of the same policy, because a loop is a cost problem before it is anything else.
3. A record of every action
Log each tool call with its arguments and result. The MCP spec already asks clients to do this: it says clients SHOULD "log tool usage for audit purposes" and "implement timeouts for tool calls," and that servers MUST "rate limit tool invocations" (MCP tools specification). The log is how you find where the agent went off the rails and what to roll back.
How to start on an existing agent
- Call
tools/liston every connected server and write down the count per server. - For the last week of runs, mark which tools were actually called.
- Disconnect or filter out anything unmarked.
- Add a run budget with a timeout, and decide what happens when it trips: stop and report, not retry.
- Turn on per-call logging if you don't have it.
None of this needs a new framework. It is an allowlist, a budget and a log.
The part I haven't solved
A fixed allowlist works when you know the task in advance. Many agent tasks don't announce what they'll need. Anthropic's answer is to let the model search for tools on demand instead of loading all of them; that addresses the token cost, but the permission question remains: a tool the model can find is a tool it can call. The options I'm weighing are a planner step that requests tools before execution, with a person or policy approving any escalation, versus narrow pre-built profiles per task type.
If you've built either, I'd like to hear how it held up.
Top comments (26)
Good framing: treating every connected tool as both token cost and executable permission means the same inventory answers both questions. The 55K-token example from Anthropic is a useful reminder that tools/list is not free - five servers exposing 58 tools can eat your context budget before the first turn. A deny-by-default sandbox per agent plus a per-task allowlist is exactly how I would scope MCP access: it is much easier to audit which server exposed which schema than to guess what the model 'probably' calls.
That is how I would expose it too. Before a run starts, I would show a small scope manifest with the tools available, the data classes each tool can touch, and the actions that require approval. The agent can then work from a task-specific allowlist rather than a broad server inventory. I would keep the manifest read-only during the run and record any attempted call outside it. How would you want the interface to distinguish a tool that is available for context from one that can perform a write?
I would not trust the label
I would make it operational.
Two separate namespaces in the manifest, read and write, and a tool declares one, never both, if it does both, it gets split.
Reads are auto-allowed inside the declared data classes, writes need an approval token bound to tool, target and argument shape, and single-use.
In the UI I would show reads as a flat list and put writes behind the approval panel with a preview of what changes.
The manifest stays read-only during the run and every call outside it gets recorded as an attempt.
I'd make one more cut on the read side before auto-allowing it. A read tool that takes a URL or a search query can move data out as easily as a write. An instruction injected into a fetched page can ask the agent to request example.net/?q= followed by whatever it just read, and the manifest records that as a read inside a declared data class.
So I'd sort tools by where their arguments go, not only by what they return. Reads against your own stores stay auto-allowed. Any call that puts agent-chosen text into a request to another host goes in the write namespace, and its approval names the destination host.
The single-use token bound to tool, target and argument shape I'd copy as written. Binding it to the target is what stops an approval for one recipient being reused for a different one later in the run.
Agreed on both, and the argument-destination cut is the one I would make first. I would add that the destination is not just the host: the channel matters too. A query string ends up in a proxy or resolver log, a POST body often does not, and a DNS name is invisible unless you keep the resolver log. So the approval should name host, method and where the data lands, and the telemetry that proves it has to exist on that channel. Without that log the manifest is a promise, not a control. On the token, I would also expire it at the end of the task, not only bind it to target and argument shape: that is what stops a later step in the same run from replaying it.
For DNS I'd go one step past the log, because the lookup itself carries the data. A query for
<encoded-text>.attacker.examplehas already left by the time the resolver writes the line, so the log tells you what leaked rather than stopping it.I'd point the agent's sandbox at a resolver that only answers names on the task's allowlist and returns NXDOMAIN for everything else, then block outbound port 53 and the public DNS over HTTPS endpoints so the agent can't choose its own resolver. The resolver log then becomes a record of attempts, which fits your rule that every call outside the manifest gets recorded as one.
The allowlist-resolver design is the one I would make non-negotiable - it converts DNS from an exfiltration channel into an audit log by construction. The missing piece is per-task attribution: if every sandbox shares one resolver, a spike in NXDOMAINs tells you something leaked but not which task; tagging queries with a per-task suffix keeps the log joinable to the run.
I'd attribute by where the query came from rather than by a suffix in the name, because the agent writes the name. A suffix added inside the sandbox, through a search domain for example, only applies to short names, and a fully qualified lookup skips it. If each sandbox gets its own resolver listener, or its own source address on a shared one, the resolver log records the task without needing the agent's cooperation.
On the CA from your other reply, agreed. I'd generate its key during the task's setup, keep it in memory, and let it go with the sandbox, so once the task ends there's no key left to leak.
Source-based attribution is the right call, and I would push it one step further: bind the resolver listener inside each sandbox network namespace (or hand each sandbox its own unix socket into a shared resolver with SO_PEERCRED), because source-IP attribution only holds while the agent cannot reach the resolver from outside its namespace. An address that is spoofable across namespaces is just name-based attribution wearing different clothes. One operational caveat: once you log every query, routine traffic (package resolution, time sync) becomes the baseline, so tier it - task-scoped lookups through the allowlist-resolver, everything else denied by default or at least flagged. Otherwise exfiltration hides in normal traffic and the audit log drowns.
On the CA: agreed on ephemeral in-memory keys. The residual risk is not persistence, it is issuance - while the sandbox is alive, that CA can sign anything within its validity window. Scope it down: minute-scale TTLs on issued certs, and bind the CA to a SAN/URI prefix if you can, so even a compromised CA cannot mint arbitrary identities. Log each issuance into the same audit log as the resolver entries, attributed to the task, so you can reconstruct which task CA signed what after the sandbox is gone.
I'd get that scope from the standard Name Constraints extension rather than a prefix convention, and I'd put it on an intermediate rather than the root. RFC 5280 says name constraints are not applied to self-issued certificates, so a constraint written into the per-task root doesn't limit what that root signs. At setup I'd generate the root, sign one intermediate whose permitted dNSName subtrees are the task's allowlist, discard the root key, and give the TLS-terminating proxy only the intermediate. The proxy only ever needs to impersonate allowlisted hosts, so the constraint costs nothing, and a stolen intermediate key can't mint a certificate for a host off the list.
One gap to close in the same extension: the RFC also says a restriction applies only when that name form is present, so a certificate carrying nothing but an IP address SAN passes a dNSName-only constraint. I'd exclude 0.0.0.0/0 and ::/0 under iPAddress as well.
Good catch on the self-issued exemption - that is exactly why the constraint belongs on the intermediate; discarding the root key afterward also leaves the proxy holding a single revocable credential. On the iPAddress gap: worth alerting when any issued cert carries an IP SAN at all, since in a resolver-bound design the agent should never need one - it is a strong signal that something is bypassing the allowlist.
The thing I like most here is collapsing cost and permission into one inventory — people treat them as two different review meetings when they're the same list. One wrinkle I keep running into: the inventory isn't static within a run. Servers can change what
tools/listreturns between sessions, and a long-horizon agent re-reads those descriptions every turn, so the 55K isn't a one-time tax, it's paid on every context rebuild unless you cache aggressively. The other place I'd push: the allowlist guards which tools exist, but description text is still attacker-controlled surface — a tool whose description quietly says "always call me first" can hijack selection even inside a tight allowlist. We ended up auditing descriptions, not just call names, in the log. Curious how you handle servers that expand their tool set mid-engagement — do you fail closed on anything not in the original allowlist, or re-approve? Fail-closed sounds right but breaks every time a vendor ships a new tool.That exposes two limits in my draft. I treated tools/list as a discovery snapshot, but a long-running run needs a versioned inventory: cache it within a run, revalidate when a server changes, and make the invalidation visible to the policy. I also agree that the allowlist cannot treat prose descriptions as authority. I would let policy match server, tool, and schema capabilities, while retaining descriptions as untrusted context and logging any mismatch. What signal do you use to detect a registry change without turning every turn into another full discovery pass?
Name constraints on the intermediate is the right layer: it also gives you a clean rotation story, because re-scoping a task means signing a fresh intermediate from the offline root rather than touching the proxy chain, and every certificate the proxy ever presented chains to exactly one task, which keeps the post-hoc audit joinable. Discarding the root key is what makes that enforceable - after that step nothing can mint a second intermediate outside the allowlist, so the constraint stops being policy and becomes arithmetic.
One correction to the rotation story, because it depends on the root key. Once the root key is discarded at setup, nothing can sign a fresh intermediate later, so there's no offline root to re-scope from. Keeping one offline would bring back the thing discarding it removed: a key that can mint a second intermediate for any host.
So I'd re-scope by starting over. A new task gets a new root and a new constrained intermediate, and only that root goes into the agent's trust store for the run. The audit stays joinable for the reason you gave, since every certificate the proxy presented chains to the one root generated for that task. On the IP SAN alert, I'd keep it as a canary, but with 0.0.0.0/0 and ::/0 excluded, a client that enforces name constraints already rejects that certificate, so the alert mostly tells you the issuer is misconfigured.
Fair correction: once the root is discarded there is no offline root to re-scope from, so a new task needs a new root and a new constrained intermediate. That keeps the trust store per-run, which is what stops a stale intermediate from being reused in another task.
The distinction between what an agent can do and what it should be allowed to do is important.
At 123sudo, we’re interested in making those boundaries understandable to everyday users, not just developers.
How do you approach showing someone the agent’s scope before they start a task?
I would show two boundaries before execution: the tools connected to the task and the effects each tool may produce. A read-only lookup can sit in the first group, while a write, external message, or file change appears in the second with its approval condition. That gives a person something concrete to review instead of a general statement that the agent is safe. What would make that boundary understandable to someone who does not work with the underlying tools?
I’d explain it in terms of outcomes rather than tool names: “This agent can read these files” versus “This agent can edit a file or send a message.”
Before an action that changes something, show exactly what will change, where, and what needs approval. For a message, that could mean showing the recipient and draft before sending.
The distinction I’d want visible is: “Connected to the agent” doesn’t automatically mean “allowed for this task.”
I'd build that preview from the tool call itself, not from the agent's summary of it. If the agent writes "I'll send the draft to your manager," the person is approving a sentence. If the gateway renders the actual payload, they see to: someone@example.net and can catch the mismatch before anything goes out. The agent can describe the action, but what the person approves should come from the call it is about to make.
For someone who doesn't work with the underlying tools, I'd flag the two details they're least likely to check on their own: a recipient outside their organisation, and a file outside the folder the task started in. Those are the ones I'd show in a different colour, because a quick reader skims straight past them.
That’s a helpful distinction: approving what the agent says it will do isn’t the same as approving the actual action.
Showing the recipient and target file directly from the pending tool call would give me something concrete to check. I’d pair the colour with a clear label like “External recipient” or “Outside task folder,” so the warning doesn’t depend on colour alone.
If the agent changes the payload after approval, would you require a fresh approval?
Yes, and I'd make that the gateway's job rather than a rule the agent follows. The approval stores a hash of the exact payload it showed you (tool, target, arguments), and the gateway executes that stored payload itself. If the agent sends anything else, even one changed recipient, the hash doesn't match and you get a new preview. That also covers the quieter case where the agent gets approval on a draft and then edits the body before sending.
Agreed on the label. WCAG 1.4.1 says colour can't be the only visual means of conveying information, so "External recipient" next to the address is the right pairing, and it also reaches someone using a screen reader.
Good catch that the lookup itself carries the data - two leaks survive even an allowlist-only resolver: the SNI in the TLS ClientHello and the HTTP Host header both carry names without touching DNS, so I would enforce the same name list at the egress proxy as well. And NXDOMAIN is observable by the agent (failure counts and timing), which is itself a low-bandwidth covert channel, worth rate-limiting or jittering negative responses.
Agreed on SNI and Host, and the egress proxy is where I'd check both. One detail decides how much the proxy can see: SNI travels in the clear in the ClientHello, but the Host header is inside the encrypted stream. An agent can send SNI for an allowed name on a shared CDN and a Host header for a different site behind the same CDN, which is domain fronting. So either the proxy terminates TLS, which is reasonable in a sandbox where you own the CA, or the list only holds names whose provider refuses a Host that doesn't match the SNI.
I'd push back a little on the NXDOMAIN channel. Failure counts and timing are visible to the agent, but a covert channel needs a receiver outside, and the attacker never sees those queries if the resolver answers them locally. That's the property I'd test: a name off the list is never forwarded upstream, so no authoritative server the attacker runs ever receives it. If that holds, jittering NXDOMAIN adds latency without closing anything.
The SNI-in-clear point is what decides the architecture: once you accept that a proxy can see SNI but not Host unless it terminates TLS, validation has to happen at termination, which means the sandbox owns a CA - fine per-task, risky if that CA outlives the task. On NXDOMAIN, pinning never-forwarded-upstream as an invariant is the right test; I would also log resolver-side query counts per task so a spike in local NXDOMAINs becomes a signal even when nothing leaves the host.
Agreed on both, and the argument-destination cut is the one I would make first. I would add that the destination is not just the host: the channel matters too. A query string ends up in a proxy or resolver log, a POST body often does not, and a DNS name is invisible unless you keep the resolver log. So the approval should name host, method and where the data lands, and the telemetry that proves it has to exist on that channel. Without that log the manifest is a promise, not a control. On the token, I would also expire it at the end of the task, not only bind it to target and argument shape: that is what stops a later step in the same run from replaying it.