Tools and MCP: Design the Integration Contract
Tuesday, 3:02 p.m. Ticket #5590 asks for one thing: downgrade one account. Scribe writes one line of SQL, matches two companies instead of one, and silently downgrades a customer who never asked for it.
Turn a tool from a loose function call into a narrow, checkable contract — schema, scope, idempotency, and typed errors — and prove it holds under three deliberate attacks.
What you will be able to do
- Turn a function into a narrow tool contract.
- Enforce schema, scope, permission, idempotency, and typed errors.
- Prove a consequential tool resists duplicate and over-broad requests.
- Prerequisites
- Agent Engineering: From Prompt to Reliable Work Loop
- Assumed knowledge
- Can read a function signature and JSON schema.
- Evidence you will produce
- A bounded account-plan tool with deterministic contract tests
- Workload
- 40 min guided lesson + 45–90 min independent build
The incident
TL;DROne free-text tool, one wildcard match, two customers changed instead of one — and nothing in the run noticed.
Meet Scribe, a billing-ops agent with exactly one tool: query_database, which takes any string and runs it. Ticket #5590 asks for a single, specific downgrade — account acct_7731, Vantage Freight, Pro to Starter.
Watch what Scribe writes instead of what the ticket says. Your job is the same as a reviewer's: spot the line where 'one account' became 'every account that matches a pattern.'
- Press play. A few seconds of real work, slowed down enough to catch the lie.
Scribe's query ran without an error and returned exactly what SQL says it should. So where did this run actually go wrong?
The diagnosis: the tool wasn't a contract
TL;DRA tool is a promise about what can and can't happen. query_database made no promises at all.
Scribe didn't misunderstand the ticket. It picked the only tool it had, and that tool let it express a query far broader than the task needed. A permissive input schema isn't a convenience — it's an invitation to match more than you meant.
A tool contract is the fix: a name and purpose narrow enough to review at a glance, an input schema that rejects ambiguity instead of accepting it, an authorization scope enforced in the handler, an idempotency rule for repeats, and typed errors that tell the caller whether to fix, retry, ask, or stop.
Here's the contract that would have stopped ticket #5590 cold:
- Schema
- account_id (exact match, required), target_plan (enum: starter|pro|enterprise). No free text, no LIKE, no SQL.
- Authorization
- account_id must resolve inside the requesting tenant. A match outside scope returns denied, not a result.
- Idempotency
- request_key required. Same key twice returns the same result — never a second update.
- Errors
- Ambiguous or missing input returns a typed error. The handler never guesses which row you meant.
Go deeper
Why MCP matters here isn't magic — it's standardization. The value of a shared protocol is that a tool's schema, error shape, and result structure mean the same thing to every host and every model that connects to it, so a reviewer can audit the contract once instead of re-deriving it per integration. The discipline (narrow schema, explicit authority, typed errors) is what actually prevents incidents; the protocol just makes that discipline portable.
Watch for a specific smell: a tool that forwards the caller's own login token or a shared admin credential straight to the backend, instead of the handler minting its own scoped grant per call. That passthrough pattern looks convenient — one less thing to configure — and it is exactly how a tool ends up able to do more than its schema advertises. The schema only means something if the credential behind it can't exceed it.
Your team is wiring an agent to a tool that cancels subscriptions. Which line belongs in its contract?
The mechanism: five parts of a tool contract
TL;DRSchema narrows the ask. Scope narrows the authority. Idempotency narrows the repeat. Errors narrow the guess. Receipt narrows the doubt.
Every call through a well-built tool passes through the same five checks, in the same order, whether it's MCP, a REST wrapper, or a function call. Validate rejects malformed or overly broad input before anything else runs. Authorize confirms the caller may act on this specific record, not just on records like it.
Execute performs only the one operation the schema describes — never a broader query that happens to satisfy it. Verify checks the actual effect against what was expected: one row, not two. Record writes a receipt naming the tool, the input, the outcome, and anything denied along the way.
Most tool incidents live in the gap between two of these: a schema that's technically valid but too broad (validate without real scope), or an execute step with no verify behind it (Scribe's exact failure). Name the five parts and you can point at which one was missing.
In the incident, Scribe's UPDATE ran against two rows instead of one. Which of the five parts, if it had existed, would have caught that before send_confirmation fired?
Scribe, under contract
TL;DRSame ticket, same task — now behind a schema, a scope check, and an idempotency key. Then try to break it three ways.
This is ticket #5590 again, but query_database is gone. In its place: a read-only lookup, a narrow typed update, and the old free-text tool — still present, still denied. Run it clean first, then switch to Break it and try the three moves a careless build would let through.
- Press play. A few seconds of real work, slowed down enough to catch the lie.
In the duplicate-request break, why did the retried call return the original result instead of downgrading the account a second time?
Build your own account-plan tool
TL;DRTake this contract to opencode and build the adapter for real. Your artifact is the receipt that proves the contract held.
Everything above ran in the page. Now build it where it counts — your machine, real fixture files, a real handler function. Open a disposable directory, start opencode, and hand it the build brief below. You're not writing the code yourself; you're holding the contract while an agent writes it, then trying to break what it built.
When you have a receipt, run the three injections from Break it against your own adapter: the ambiguous lookup, the duplicate request, and the reach for the forbidden tool. If all three are caught, you've shipped the chapter's artifact. If one gets through, you've found a real gap in a contract you wrote yourself — the best place to find it.
Build a small tool adapter in this empty directory for one capability: change an account's plan. Contract: (1) Fixtures: accounts.json with at least two accounts whose emails share a substring (e.g. vantage@vantagefreight.io and billing@vantagerobotics.com) — write this yourself first. (2) Tools: lookup_account(account_id) is read-only and exact-match only, no name or email search. update_account_plan(account_id, target_plan, request_key) is the only way to change a plan; target_plan must be one of a fixed enum. run_sql(query) must exist in the code but be denied by an allowlist before it ever reaches a handler, and the denial must be logged. (3) Validate must reject any input field not in the schema and must reject a missing or malformed account_id — never guess or pattern-match. (4) update_account_plan must be idempotent: calling it twice with the same request_key must produce exactly one row change and return the same result both times. (5) Every call — allowed, denied, or rejected — appends a structured entry to run-trace.jsonl, and a successful plan change writes receipt.json with account_id, old plan, new plan, request_key, and row count. No network calls. No real customer data. Then show me the receipt from one clean run, one run where you send an ambiguous or extra-field input, and one run where you retry the same request_key twice.
- Clean run: receipt.json shows exactly 1 row changed on the intended account_id
- Ambiguous/extra-field input: validate rejects it before any lookup or update runs
- Duplicate request_key: second call returns the first call's result, 0 additional rows changed
- run_sql attempt: denied at the tool layer and recorded in run-trace.jsonl
- You can explain what the artifact proves — and what it does not prove — in two sentences
What the receipt proves: on these fixtures, this adapter enforces exact-match input, one-row effect, and idempotent retries — every time you ran it. What it doesn't prove: that a real database enforces the same uniqueness guarantees, that a live MCP host wires authorization the way this mock assumes, or that the credential behind the real handler can't exceed the schema in production. Those are separate verification tasks, not conclusions you get for free from a local pass.
Built it? Paste your receipt.json. We verify the evidence structure locally; we do not pretend that valid JSON proves the build works.
What you're leaving with
TL;DRA tool is a contract, not a convenience — narrow schema, enforced scope, idempotent writes, typed errors, and a receipt that shows all four held.
You watched one free-text tool silently mutate the wrong customer's account, named the five checks that would have stopped it, and built a typed adapter that caught an ambiguous match, a duplicate retry, and a reach for the forbidden shortcut. The next chapter puts several tools behind one agent and asks the harder question: when two tools could both plausibly finish the job, who decides which one runs?
- Tool contract
- Name, purpose, input schema, authorization scope, idempotency rule, and error contract — written before the handler.
- Idempotency key
- A request identifier that guarantees a repeated call returns the original result instead of acting twice.
- Least-privilege scope
- A tool can only reach what its schema and handler explicitly grant — never everything the underlying system allows.
- Typed error
- A stable error category (invalid input, denied, not found, timeout) that tells the caller whether to fix, retry, ask, or stop.
- Credential passthrough
- Forwarding a caller's own token straight to a backend instead of minting a scoped grant — a smell, not a shortcut.
- Model Context Protocol Specification 2026-07-28 — MCP project ↗The standard this chapter's adapter imitates — tool schemas, results, and errors as a portable contract.
- MCP Authorization — MCP project ↗Why scoped, per-call authorization beats forwarding a caller's raw credential to the backend.
- Tool use with Claude — Anthropic ↗How schema design and error messages shape whether a model can recover from a bad call or just repeats it.
- RFC 9700: OAuth 2.0 Security Best Current Practice — IETF ↗The security case against token passthrough — the pattern behind this chapter's credential-scope warning.