Claude Code has approval to call release.create_draft. The MCP method accepts a service name, commit, and release notes. It creates a draft release record and cannot deploy anything.
A week later, the platform team ships a compatible-looking update. The method name stays the same. The new schema adds publish: boolean, defaulting to false, and the server identity still passes authentication. Another update changes the backend implementation so a draft for a production service also reserves a deployment slot.
The agent’s permission file has not changed. Its authority has.
A method name is a weak permission boundary. Before Claude Code uses a consequential MCP tool, I want the run to pin the exact contract it reviewed: server identity, schema, implementation, target scope, side-effect class, and policy version. If the live tool differs, the call stops.
the method name does not describe the authority
Teams often write permissions like this:
allow:
- mcp.release.create_draft
That line says which route the agent may call. It does not say what the route can do today.
The effective capability comes from several moving parts:
server identity
+ method schema and defaults
+ implementation and dependencies
+ credentials used by the server
+ target resource policy
+ side effects and downstream triggers
= actual authority
Any one of them can drift without changing the method name. A read method might start warming a cache that triggers billing. A ticket method might gain a default assignee with access implications. A deployment preview might begin writing to a shared environment.
This is why classifying tools by their effects matters. The permission should describe the action the system can cause, not the friendly verb in its API name.
issue a capability manifest at approval time
Capture the reviewed contract as an immutable manifest:
capability_manifest:
id: cap-release-draft-7f38
issued_for:
run_id: cc-run-2941
task_id: release-payments-2026-08-12
server:
identity: spiffe://platform/mcp/release
version: 4.8.2
method:
name: release.create_draft
schema_digest: sha256:542a...
implementation_digest: sha256:a7c1...
arguments:
allowed:
service: payments-api
commit: 8bc911d
forbidden:
- publish
- environment
effects:
class: reversible_external_write
may_trigger:
- draft_release_created
must_not_trigger:
- deployment_slot_reserved
- production_rollout_started
policy:
version: release-policy-31
expires_at: 2026-08-12T12:00:00Z
The schema digest should cover normalized argument types, required fields, defaults, and response shape. Defaults matter. Adding an optional flag can change behaviour even when old requests still validate.
The implementation digest can identify a signed server artifact, image, function package, or release attestation. Hashing source files from the client is usually the wrong layer. The runtime or deployment system should attest the artifact that is serving the request.
The effect list is the part most API contracts omit. Write it from observed and governed behaviour: records created, queues published, workflows started, credentials exercised, and systems reached. If the platform cannot name a tool’s possible effects, it is not ready to grant that tool to an autonomous run.
compare the manifest at dispatch
Do not check the manifest once when the session starts. Check it at the gateway immediately before every consequential call.
1. Claude Code submits method, arguments, and capability manifest ID.
2. Gateway fetches fresh server and deployment attestations.
3. Gateway normalizes the live schema and computes its digest.
4. Policy resolves the target and permitted effect class.
5. Gateway compares every bound field with the manifest.
6. Exact match allows dispatch. Any mismatch stops the call.
The agent should not perform this comparison itself. It can propose a call, but the gateway owns the decision. A prompt instruction such as “check that the schema has not changed” fails if the agent receives stale tool metadata or decides that a small difference looks harmless.
Here is a useful decision record:
capability_check:
manifest_id: cap-release-draft-7f38
checked_at: 2026-08-12T10:51:09Z
live:
server_identity: spiffe://platform/mcp/release
server_version: 4.9.0
schema_digest: sha256:8b20...
implementation_digest: sha256:ce11...
policy_version: release-policy-31
differences:
- field: method.schema_digest
expected: sha256:542a...
actual: sha256:8b20...
- field: effects.may_trigger
added: deployment_slot_reserved
decision: deny_contract_drift
dispatched: false
A version difference by itself need not be fatal if all bound properties remain provably identical. In practice, it is safer to bind the signed artifact digest and semantic contract rather than make the version string the whole control. The decision record should still preserve the version for investigation.
test drift as a denial path
A capability manifest is useful only if the runtime refuses near matches. Add denial fixtures before granting the tool:
denial_evals:
- name: optional_publish_argument_added
mutate: schema
expect: deny_contract_drift
- name: default_target_changes_to_production
mutate: argument_default
expect: deny_contract_drift
- name: same_schema_new_side_effect
mutate: implementation_attestation
expect: deny_contract_drift
- name: server_uses_wider_service_identity
mutate: server_identity
expect: deny_contract_drift
- name: policy_changes_during_run
mutate: policy_version
expect: deny_contract_drift
Include one allow fixture for the exact approved contract. Then change one property at a time. This separates a working permission from a gateway that denies everything.
The awkward fixture is same_schema_new_side_effect. Schema checks cannot catch it. The implementation attestation and effect policy must. That is also why a green result against a mock MCP server does not prove the production tool stayed inside its approved boundary.
make renewal a new decision
When a check fails, do not silently refresh the manifest and continue. That turns attestation into paperwork.
Stop the call and return a contract delta to a named reviewer:
renewal_request:
old_manifest: cap-release-draft-7f38
requested_change:
schema: adds optional publish flag
effects: adds deployment_slot_reserved
task_still_requires_tool: true
safer_alternative:
method: release.create_local_record
effects: [local_file_write]
required_decision:
- reject_change
- approve_new_manifest
- choose_safer_alternative
Approval belongs to the new capability, not to the old method name. Issue a fresh manifest with a short expiry and bind it to the current run, task, arguments, and artifact. Record the reviewer and reason.
If the run can use a narrower path, prefer it. Producing a release file for human submission may be slower than creating the external record directly, but it preserves the original authority boundary while the tool change is reviewed.
This follows the same discipline as binding test evidence to the code it tested and rehearsing rollback against the current artifact. Evidence, recovery, and permission all expire when the thing they describe changes.
put the contract check in the review packet
The final review packet should identify both the approved and live capability:
mcp_use:
manifest_id: cap-release-draft-7f38
server_identity: spiffe://platform/mcp/release
schema_digest: sha256:542a...
implementation_digest: sha256:a7c1...
policy_version: release-policy-31
dispatches: 0
denied_calls: 1
denial_reason: contract_drift
external_effects_verified: []
Zero dispatches can be the correct outcome. The agent found that yesterday’s permission no longer described today’s tool and refused to inherit the difference.
My rule is simple: approve a capability manifest, verify it at dispatch, and stop on any unreviewed delta. Claude Code should never gain new authority because an MCP method kept its old name.
Claude Code: Building Production Agents That Actually Work covers MCP boundaries, permission changes, denial evals, observability, rollback, and review packets for production coding-agent work.