DEV Community

berthelius for Frihet

Posted on

Designing MCP tools an agent won't misuse

The first article in this series made a pair of claims. One: an AI-native product is an MCP server that lets an agent do things, not a chat widget that talks about them. Two: every write an agent can invoke needs idempotency and typed, recoverable errors, because agents retry and fan out by default.

Both took typed schemas for granted. Of course you validate your inputs — zod on the way in, reject the malformed call. That's table stakes. This article is about the failure mode that survives input validation: misuse.

An agent almost never sends you malformed JSON. It sends you well-formed wrong. The payload passes every schema check and still does the wrong thing, because a language model is a probabilistic caller reasoning from your tool descriptions, not a developer who read your docs. Typing the inputs stops garbage. It does nothing about a valid call that shouldn't have been made. Designing against that is a different job.

Four ways a well-typed call still goes wrong

Watch a model drive a real tool catalog and the same four failures recur:

  1. Wrong tool. You expose update_invoice and send_invoice. The agent means "send" but the descriptions overlap, so it calls "update" with a status field and assumes that mailed the PDF. Every argument is valid. Nothing was sent.
  2. Valid-but-wrong argument. A field takes a free-text region string. The model writes "Canary Islands", "canarias", "ES-CN", or "islas canarias" on different runs. All are strings. Your tax engine understands exactly one of them.
  3. Wrong order / missing precondition. The agent issues a credit note against an invoice that was never finalized, or bills a client it hasn't created. The call is shaped correctly; the world isn't in the state the call assumes.
  4. Can't recover. Something fails and the error is a stringified 500. The model can't tell "back off and retry" from "this will never work," so it either gives up or hammers the endpoint.

None of these is an input-validation bug. All four are interface design bugs. The good news: the same MCP surface that lets an agent operate your domain also gives you the levers to prevent them.

Lever 1 — constrain the input space, don't just type it

region: z.string() is typed. It is also an open door: infinitely many valid strings, one of which your server accepts. The fix is old and boring — make illegal states unrepresentable — and it maps directly onto tool schemas. Replace the open type with a closed one, so the model chooses from a menu instead of inventing a value.

Here's the real shape from the Frihet MCP server's invoice tools. The fiscal zone that decides whether a line carries mainland IVA, Canary Islands IGIC, or an exemption is not a string — it's an enum, and the operation type is a two-value enum:

// src/tools/invoices.ts — invoiceFiscalFields: fiscal zone drives IVA vs IGIC vs exempt, never a free string
clientLocation: z
  .enum(["peninsula", "canarias", "ceuta_melilla", "eu", "world"])
  .optional()
  .describe("Fiscal zone driving IVA vs IGIC vs exempt / Zona fiscal"),

operationType: z
  .enum(["service", "goods"])
  .optional()
  .describe("Operation type (service or goods) / Tipo de operacion"),

irpfRate: z
  .number()
  .min(0)
  .max(100)
  .optional()
  .describe("IRPF withholding % (retencion autonomo ES) / Retencion IRPF %"),
Enter fullscreen mode Exit fullscreen mode

Three constraints, three classes of misuse closed off. The enum collapses "Canary Islands" and its four spellings into one legal token the model can't get wrong — and the enum values are visible to the model at tool-discovery time, so it picks rather than guesses. The .min(0).max(100) on a percentage means the agent can't submit a 150% withholding because it misread a prompt. And a two-value operationType enum is a decision the model makes explicitly instead of leaving your server to infer intent.

The rule of thumb: for every input field, ask "how many values would I accept here, and how many does the domain actually allow?" When those numbers differ, the gap is where an agent will eventually land. Close it with an enum, a numeric bound, a length cap, or a discriminated union — in the schema, where the model can see it, not in a 422 it discovers after the fact.

Lever 2 — the description and the annotations are part of the contract

A tool's description is not a comment. It's the only thing the model reads to decide which tool to call. Overlapping, vague descriptions are how you get the wrong-tool failure. Two tools whose descriptions could each plausibly answer "send this invoice" is a routing bug you shipped, and no amount of input typing fixes it. Descriptions should be disjoint and imperative: name the one job each tool does and, where useful, name what it does not do.

MCP gives you a second, machine-readable channel for this: tool annotations. They're advisory hints attached to each registration — not enforcement, but signals the host can act on. The Frihet server tags every tool with one of four constants:

// src/tools/shared.ts — safety annotations, applied per tool
export const READ_ONLY_ANNOTATIONS = { readOnlyHint: true,  destructiveHint: false, idempotentHint: true,  openWorldHint: false };
export const CREATE_ANNOTATIONS    = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
export const UPDATE_ANNOTATIONS    = { readOnlyHint: false, destructiveHint: false, idempotentHint: true,  openWorldHint: false };
export const DELETE_ANNOTATIONS    = { readOnlyHint: false, destructiveHint: true,  idempotentHint: true,  openWorldHint: false };
Enter fullscreen mode Exit fullscreen mode

Read what those flags encode. A read tool is safe and idempotent — a host can auto-approve it. A create tool is idempotentHint: false: calling it twice is not safe, which is precisely the signal that says "this write needs an idempotency key". A delete tool is destructiveHint: true — the flag a well-behaved client uses to require human confirmation before letting an agent run it unattended.

These are hints, and the spec is explicit that a client shouldn't make security decisions on annotations alone — real enforcement still lives server-side in auth and validation. But as a design tool they're doing something important: they let the host gate the agent's autonomy per tool instead of all-or-nothing. Auto-run the reads, checkpoint the deletes. Misuse-by-blast-radius, contained by metadata you declared once.

Lever 3 — type the output and the errors, so recovery is deterministic

The last two failure modes — wrong order and can't-recover — are both about what the model learns after the call.

Typed output is what lets an agent chain calls without hallucinating the shape of your data. On the Frihet server every tool returns structured output via an outputSchema, and list tools return real pagination — { data, total, limit, offset } — so an agent pages deterministically instead of guessing whether it saw everything. Prose replies force the model to parse, and parsing is where it invents fields that were never there.

Errors are the other half, and they're where "can't recover" is won or lost. A stack trace tells a model nothing. A stable, typed error tells it what went wrong and what to do next. The server maps every failure onto one error class with a machine-readable code:

// src/client.ts
export class FrihetApiError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly errorCode: string,   // stable string, e.g. "rate_limit_exceeded"
    message?: string,
  ) { super(message ?? errorCode); }
}
Enter fullscreen mode Exit fullscreen mode

The agent branches on errorCode, never on a stringified status. A rate_limit_exceeded means back off — and the server already retries 429s with exponential backoff before it ever surfaces one, honoring the Retry-After header. A request_timeout means the write may or may not have landed, so retry with the same idempotency key. A validation code means the payload is wrong; retrying it unchanged just burns the 100-requests-per-minute budget against a wall. (The retry taxonomy itself — retryable vs terminal, backoff with jitter — is a subject of its own; the point here is that the code is the interface that makes those decisions possible.)

Same idea for the wrong-order failure: return a precise, named error the agent can act on — "finalize the invoice before issuing a credit note" — instead of a generic 400. A typed error is an instruction. A 500 is a dead end.

The design review that catches misuse

Before you register a tool, run it past four questions. They map one-to-one onto the failure modes:

  • Wrong tool? Is this tool's description disjoint from every other tool's, and does it name the one job it does? Could a model reasonably confuse it with a neighbor?
  • Valid-but-wrong argument? For every field, does the schema allow only what the domain allows — enums over free strings, bounded numbers, discriminated unions — with the legal values visible at discovery time?
  • Autonomy gating? Are read / create / update / delete annotated so a host can auto-approve the safe ones and checkpoint the destructive ones? Does the create tool that isn't idempotent say so?
  • Recoverable? Does every failure return a stable errorCode and structured output the agent can branch on and page through — never a stringified 500?

Typed inputs keep malformed calls out. Designing the surface — constrained schemas, disjoint descriptions, safety annotations, typed errors — keeps wrong calls out. The first is validation. The second is the actual interface an agent operates, and it's the one that decides whether 157 tools are 157 capabilities or 157 ways to get it subtly wrong.

Earlier in this series: why AI-native means an MCP server, not a chatbot.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The missing lever is server-enforced authority and state, because descriptions, schemas, and annotations mostly shape selection—they do not make a valid call permissible. I’d derive tenant, principal, and allowed resource scope from authenticated server context, never agent-supplied fields; evaluate object-level authorization on every call; and return the current resource version plus legal next transitions from read tools. Writes should require that version (ETag/If-Match or an equivalent precondition) so an agent cannot act on stale state between planning and execution. For destructive or externally visible effects, split plan_* from execute_*: the plan returns a canonical operation digest, impact summary, required approval class, and expiry; execute accepts that digest and revalidates auth, state, and scope. Then test confusion explicitly with near-neighbor prompts, cross-tenant IDs, stale versions, duplicated calls, revoked approval, and timeout-after-side-effect. That turns “won’t misuse” from a prompt-level hope into an enforceable invariant.