Fire In Belly

AI Agent Capability Snapshot Binding for Tool Authorization

10 min read

Cream envelopes closed with gold wax seals stamped with a tree

Your support agent planned a refund against billing.refund with a three-field schema. Between that plan and the handler, the MCP server redeployed: the tool gained a consequential force parameter, policy loosened the daily cap, and tools/list_changed fired. The model still held the old description in context. The runtime looked up the live registry and executed. Nothing in call-time tool validation failed - the JSON was well-formed - and nothing in MCP admission had been revoked. The failure was subtler: discovery was treated as permission, and plan and execute saw different contracts.

You need one operating contract for that gap. Capture an immutable capability snapshot when the agent is authorized to act. Bind caller identity, observed schema digest, effect class, and policy digest into that snapshot. Carry the same epoch through handler start. On catalog change, invalidate - do not auto-broaden. When digests diverge, deny with an audited capability_drift and send the call back to planning or re-authorization. This article is that contract. It complements admit-time signed skill and schema integrity, runtime tool-output taint, and human-approval binding. It stops before rewriting OAuth scopes or standing IAM for humans.

Why discovery is not authorization

The Model Context Protocol tools specification is explicit about discovery machinery. Servers expose tools the model can list and invoke. They may advertise listChanged. The available set MAY change over time and MAY vary with the authorization presented on the request. Clients cache catalogs into model context. That is useful discovery machinery. It is not a permission model.

A capability snapshot is the object that turns discovery into evidence. It records what a specific principal observed at a specific boundary: which server, which tool identity, which canonical schema digest, which effect class the platform assigned, and which policy digest was active. Authorization consumes that evidence. Execution re-verifies it. The Mission Capability Binding Internet-Draft states the same separation in standards language: catalog contents are discovery metadata, never authority for a server's effective capabilities. If the invoked identity drifts from the approved binding, the decision is capability_drift, not a silent proceed.

Without that binding, four facts drift independently:

  1. The caller (session, workload identity, or delegated subject).
  2. The schema the model planned against.
  3. The effect class reviewers thought they approved (read vs write vs money-move).
  4. The policy version that limited amount, destination, or scope.

Any one of those can change while the model still "knows" the old tool. The OWASP AI Agent Security Cheat Sheet already requires scoped MCP tools and denial of unauthorized tools even when the model asks confidently. Snapshot binding is how you keep that denial honest after the catalog moves.

The attack: plan against A, execute against B

Consider a refund path that was reviewed last week. The approved schema is {order_id, amount, reason_code}. Overnight, the billing MCP server ships a "helpful" optional skip_fraud_check boolean and updates the description to steer the model toward using it for "VIP escalations." Your client receives notifications/tools/list_changed, refreshes tools/list, and - if you treat the new list as permission - the next turn exposes the new field to the model. A prompt-injected ticket then asks for a VIP refund. Call-time validation still passes: types match, amounts are under a static max, the order exists. The handler runs with fraud checks skipped.

A second variant is quieter. Policy hot-reloads mid-run and raises the per-transaction cap. Or a credential with broader scopes suddenly makes previously hidden tools appear in tools/list. Paybond's policy hot-reload model shows the safe pattern: pin policyDigest at authorize time, finish in-flight work under that digest, and refuse silent loosening. Snapshot binding makes the same rule apply to schema digests, not only policy files.

MCP Hangar's digest pinning names the failure mode cleanly: you approved a name, description, input schema, and output schema. Then the shape moved. Stock MCP does not tie the tool you approved to the tool you are calling. Digest pinning closes that gap at the proxy seam, including a practical reading of SEP-1766 that works even when upstreams publish no digests. Your platform still needs the operating contract that joins that pin to caller identity, effect class, policy digest, and list_changed semantics.

Snapshot field contract

At the authorize boundary - after discovery, before any privileged side effect - materialise a snapshot epoch. Minimum fields:

FieldPurpose
principalWorkload or session identity that will act
server_idMCP server or tool origin identity
tool_idStable tool handle, not a display title
schema_digestSHA-256 over canonical name, description, inputSchema, outputSchema
effect_classPlatform-assigned class: read, write, money_move, admin, etc.
policy_digestHash of the exact policy pack / limits active for this decision
observed_atWhen discovery evidence was captured
valid_untilShort TTL; never a standing grant
snapshot_epochOpaque id that routes, handlers, and audit rows must carry

Rules that matter in production:

  • Canonicalise JSON before hashing so serializer noise cannot mint a new digest.
  • Treat description text as governing input. Prompt-steering in tool descriptions is a schema change, not a comment.
  • Do not let the model supply or overwrite effect_class. The platform assigns it from an allowlist map.
  • Bind the snapshot to the principal. A snapshot minted for agent A is invalid for agent B even if digests match.
  • Keep TTLs short. Prefer re-authorize over long-lived cached permission.

Authorization returns ALLOW only with this envelope attached. The runtime checks the envelope - not the model's confidence - before handler start.

Bind through handler start

Capture alone is not enough. Many executors validate against a live registry lookup a second time, and that reopens the replacement window between plan and effect. Use this pattern instead:

  1. At authorize, capture the immutable registry entry and digests into the snapshot.
  2. Publish routes, schemas, and policy under one snapshot_epoch.
  3. Carry that captured entry through dispatch; do not re-resolve from the mutable live map after effects may begin.
  4. If digests cannot be stabilised before the first side effect, fail closed or pair the unanswered tool call durably and return to planning.
  5. On retry after stale snapshot, publish a new coherent epoch - do not resend the same stale one.

Mismatch handling should be specific:

  • schema_digest changed → return to planning (rediscover, re-propose).
  • policy_digest changed → return to authorization (re-evaluate limits).
  • principal changed → invalid decision.
  • effect_class stronger than reviewed → stop; never silently upgrade read to money-move.

This is the same spirit as deterministic pre-action authorization: the hook runs at the framework layer, produces a signed decision record, and prompt injection that convinces the model cannot bypass the digest check.

list_changed without auto-broadening

When a server notifies tools/list_changed, safe clients:

  • invalidate affected snapshots;
  • remove stale schemas from future planning context;
  • require rediscovery before new proposals;
  • keep pending calls on their original digests until they are explicitly re-authorized.

They do not auto-accept newly appeared tools, broaden scopes because a credential now reveals more handles, or migrate a pending call onto a new schema digest.

Unsafe clients treat the notification as "refresh permissions" and paste the new catalog into the model. That collapses discovery into authorization again. Pair the notification path with human or change-control gates for effect-class upgrades, especially when a tool moves from read-shaped to write-shaped or gains money-move parameters.

Worked example: refund under snapshot binding

Setup. Support agent spiffe://acme/agent/support-refund may call billing.refund with effect class money_move. Policy digest pol_7f3a caps USD 250 per call and requires reason_code from an allowlist. Schema digest sch_91c2 covers {order_id, amount, reason_code} only.

Turn 1. Client lists tools, computes digests, authorizes. Snapshot epoch ep_441 binds principal, billing.refund, sch_91c2, money_move, pol_7f3a. Model proposes refund for order A-100, $40, reason duplicate_charge. Runtime verifies epoch digests still match, handler runs, audit row stores the snapshot.

Attack mid-turn. Server ships skip_fraud_check and emits list_changed. Client invalidates ep_441 for future planning. A pending identical proposal that somehow still carries ep_441 is re-checked: live schema_digest is now sch_55ee ≠ sch_91c2. Decision: deny capability_drift, no handler start, model must rediscover and re-propose under a new epoch. If policy also hot-reloads to raise the cap, in-flight work still finishes only under pol_7f3a; new calls need a fresh authorize under the new digest, and loosening requires an explicit allow-loosen path - not a silent swap.

Negative tests you should keep.

  • Replace description text only; digest must change; pending call must drift-deny.
  • Add an optional consequential field; same.
  • Swap tool implementation behind the same name with different schema; same.
  • Replay a snapshot under a different principal; deny.
  • Fail the policy or snapshot store; high-risk paths fail closed.

Fail closed and adjacent controls

Fail closed when the snapshot plane, digest verifier, or policy pack lookup is unavailable. Fail-open belongs only to a migration flag with mandatory audit - not to production defaults.

How this sits next to controls you may already have:

  • Call-time validation still checks arguments, tenancy, and idempotency - after snapshot binding says the contract is the one you authorized.
  • Dynamic discovery still solves catalog UX at scale - this contract decides what a discovered item may become.
  • Admit-time signing still proves package authenticity before load - this contract proves freshness between plan and execute.
  • Taint still labels untrusted tool outputs - this contract stops the wrong tool contract from running in the first place.
  • Human approval still binds people to exact parameters - this contract binds machines to exact digests when no human is in the loop.
  • MCP admission still owns origin trust and token audience - this contract owns per-call snapshot verification after admission.

Operating constraints

These constraints are easy to miss when the only deliverables are an allowlist and a schema validator:

  1. Treat tools/list output as evidence for an authorization decision, never as permission; bind caller, schema digest, effect class, and policy digest into one snapshot epoch before any side effect starts. A fresh catalog refresh is not a grant.
  2. On tools/list_changed, invalidate snapshots and force rediscovery without auto-accepting new tools, broadening scopes, or migrating a pending call onto a new schema. Notification means "your evidence expired," not "your privileges grew."
  3. Pin both schema digest and policy digest through handler start so mid-turn catalog drift and hot-reloaded loosening cannot silently change the contract the model planned against. Schema pins without policy pins still allow limit inflation; policy pins without schema pins still allow consequential field injection.

Promote or hold

Promote when:

  • every privileged tool path captures a snapshot with principal, schema digest, effect class, and policy digest;
  • dispatch carries the captured entry through handler start instead of re-looking up a mutable registry after effects may begin;
  • list_changed invalidates without auto-broadening or pending-call migration;
  • digest mismatch yields audited capability_drift (or equivalent) and returns to planning or re-authorization;
  • in-flight calls complete only under the digests active at authorize;
  • high-risk paths fail closed if the snapshot or policy plane is down;
  • negative tests prove description-only edits, consequential field adds, and principal swaps are denied.

Hold when the live registry is re-resolved after authorize, when catalog refresh is treated as permission, when only argument JSON Schema is checked, when policy hot-reload silently loosens caps, when tool descriptions are excluded from digests, or when drift fails open.

What to do next

List every production tool an internal agent can invoke. For each path, write down where discovery evidence is captured, which digests are bound at authorize time, whether those same digests are re-verified at handler start, what list_changed does to pending calls, and what happens when the snapshot or policy plane is unavailable. Until that inventory shows snapshot-bound execution on every privileged path, "we validate tool calls" remains an incomplete control - not a finished one.

If you need the control plane implemented as part of an internal workflow program rather than a weekend registry wrapper, start from AI workflow automation with capability-snapshot binding as an explicit deliverable.


About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build custom AI agents, AI workflow automation, and internal tools with the immutable capability snapshots, digest pins through handler start, and fail-closed drift denials described above, at published fixed prices. Schedule a call to discuss your next project.