The permission review looked reassuring:
METHOD report.preview
CLASS READ
APPROVAL NOT REQUIRED
CALLS 186
WRITE CALLS 0
The warehouse invoice and audit log told a different story:
PAID QUERIES 186
EXPORT OBJECTS 186
OUTBOUND WEBHOOKS 186
REPORTS MARKED VIEWED 186
CUSTOMER ROWS TOUCHED 2,418,906
report.preview did not update a business record directly. Each call still started a paid warehouse query, wrote an export to object storage, sent a webhook, and changed the report’s audit state.
The method name described the interface. It did not describe the effects.
Read and write are too small for production permissions
A source-code read is usually local and cheap. A production read may retrieve regulated data, populate a cache, reserve capacity, reveal that a customer record exists, or trigger work elsewhere.
HTTP verbs do not fix this. A GET endpoint can start a job. A GraphQL query can generate an export. An MCP method called preview can create stored data and emit an event.
Classify the method by what its full execution path can cause:
effect_classes:
data_read: true
persistent_state_change: true
outbound_network: true
billable_compute: true
sensitive_data_exposure: true
notification_or_webhook: true
idempotent: false
The tool can keep its friendly name. The policy engine should ignore that name when it decides whether the call needs approval.
Put an effect contract beside the tool schema
The JSON schema tells Claude Code which arguments a method accepts and which fields it returns. It rarely says what happens outside the response.
Add a versioned effect contract to the server definition:
method_effect_contract:
server: reporting-mcp
method: report.preview
contract_version: 12
effects:
- type: billable_query
unit: warehouse_bytes_scanned
maximum_per_call: 5000000000
- type: object_create
target: report-preview-bucket
maximum_per_call: 1
- type: outbound_webhook
destination_class: internal_reporting
maximum_per_call: 1
- type: audit_state_change
transition: unseen_to_viewed
- type: sensitive_data_read
allowed_classes: [customer_account_id, payment_status]
approval_required_when:
estimated_cost_usd_gte: 1.00
records_gte: 10000
sensitive_data: true
repeated_within_run_gte: 3
default_call_limit_per_run: 5
Version the contract with the MCP server. If either the schema or implementation changes, expire the previous permission decision. Otherwise a safe approval can survive after the method has acquired a new side effect.
This contract also gives a reviewer something testable. “Read only” is an opinion. A maximum of one export and one webhook per call is a claim that an evaluator can check.
Estimate before execution and reconcile afterwards
Before Claude Code runs the method, ask the provider for a preflight estimate:
preflight:
estimated_rows: 14000
estimated_bytes_scanned: 8600000000
estimated_cost_usd: 2.37
creates_export: true
sends_webhook: true
approval_required: true
The estimate is not evidence that the call stayed inside its boundary. After execution, collect a receipt from the systems that performed the work:
observed_effects:
warehouse_job_id: wh-example-4821
rows_returned: 13742
bytes_scanned: 8310000000
cost_usd: 2.28
export_object_id: exp-example-91
webhook_event_id: evt-example-122
audit_transition: unseen_to_viewed
independently_verified: true
Do not let the MCP server grade its own behaviour. Reconcile the warehouse job with the warehouse provider, the object with storage inventory, and the webhook with the receiving service. If an observed effect is missing from the contract, quarantine the method until someone explains the mismatch.
Give every subagent the same run budget
Per-call thresholds will not stop a loop of individually acceptable calls. The 186 previews may differ by timestamp, pagination token, or harmless formatting while causing the same business effect.
Use one budget for the parent run and all child agents:
run_budget:
warehouse_cost_usd: 8.00
sensitive_records_read: 25000
export_objects_created: 3
outbound_webhooks: 3
repeated_semantic_query_limit: 2
A subagent must debit the parent’s ledger. A new run ID is not a new allowance.
Normalize the requested outcome into a semantic effect key as well. For this example, the key might combine the tenant, reporting period, data class, and output type. The third attempt to generate the same customer payment preview should hit the repeated-effect limit even if Claude Code changes the wording or date format.
This is where cost control and permission control meet. The same loop that spends money can duplicate exports, widen data exposure, and annoy another service with repeated webhooks.
Test one undeclared effect
Build an eval where report.preview returns a normal result but emits an undeclared webhook. The fixture should fail even though the response is valid and the requested report is correct.
Add a second fixture where the estimated warehouse cost crosses the approval threshold. The query must not start before approval. Add a third where the server claims success but the provider receipt never appears. That outcome stays unresolved rather than becoming a silent success.
The release condition is specific:
release_gate:
declared_effects_observed: 7
undeclared_effects_observed: 0
unresolved_provider_receipts: 0
budget_breaches: 0
approval_bypasses: 0
result: pass
A tool that cannot produce this evidence should not receive unattended production access.
Put the receipt in the review packet
The final review packet should bind the contract version, approval, attempted calls, completed calls, blocked calls, cost, records read, external effects, and provider receipts to one run.
That is more useful than a transcript saying Claude Code used only read methods. A reviewer can see what the method caused, whether those effects were expected, and where the budget stopped repetition.
If your current permission catalogue has only read and write, start with the five MCP methods that touch production systems or paid services. Trace each method past the MCP response. Write the effect contract, add a preflight gate, and require a runtime receipt before calling any of them safe.
Claude Code: Building Production Agents That Actually Work contains the wider production model for MCP boundaries, permission budgets, observability, evals, rollback, and review packets.