All Articles

MCP in Production: Designing Tool Interfaces Agents Can Actually Use

The gap between an MCP server that works in a demo and one that holds up in production is almost never the protocol implementation — the SDKs handle that well. It’s the tool design: whether an agent given a dozen tools and an ambiguous instruction reliably calls the right one, whether a tool call can only touch what it’s supposed to, and whether you can change a tool’s behavior next month without breaking every agent already relying on it. Those are API design problems, and MCP doesn’t solve them for you just because it standardizes the transport.

Write Tool Descriptions for the Model’s Decision, Not Your Documentation

The most common failure I see in MCP servers is tool descriptions written like internal documentation — accurate, but written for a developer who already knows which tool to reach for. A model choosing between tools doesn’t have that context. It’s pattern-matching your description text against the user’s request, and if two tools have descriptions that plausibly match the same request, you’ll get wrong-tool calls no matter how good the model is.

A few things that measurably reduce wrong-tool selection:

  • Lead with when to use it, not what it does internally. “Use this to look up a customer’s current subscription status” beats “Queries the billing service subscriptions table” — the model needs to match intent, not implementation.
  • Make mutually exclusive tools mutually exclusive in description. If you have search_orders and get_order_by_id, say explicitly in each description when the other one is the better choice. Don’t leave the model to infer the boundary.
  • Constrain parameters with enums and formats wherever the domain allows it. A free-text status string invites the model to guess a plausible-sounding value; an enum of the three actual valid statuses doesn’t.
{
  "name": "get_order_by_id",
  "description": "Retrieve a single order by its exact order ID. Use this when the user provides or you already know a specific order ID. For finding orders by customer, date range, or status, use search_orders instead.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "orderId": { "type": "string", "pattern": "^ord_[a-zA-Z0-9]+$" }
    },
    "required": ["orderId"]
  }
}

That explicit pointer to the sibling tool in the description text does more to reduce misrouting than almost any amount of schema tightening on its own. When I built the routing logic in Nexus — which decides whether a request needs a single fast-path tool call or a multi-agent pipeline — the biggest single accuracy improvement came from rewriting overlapping tool descriptions to explicitly rule each other out, not from a bigger model.

Auth and Permission Boundaries Belong on the Tool, Not the Agent

It’s tempting to authorize at the agent level — this agent is allowed to act on behalf of this user, full stop — and let every tool call inherit that blanket permission. That works until an agent with broad legitimate access gets manipulated (through a prompt injection in retrieved content, or just a reasoning error) into calling a tool in a way you didn’t intend. Blanket permission means the blast radius of that one bad call is everything the agent could touch.

Scope permissions to the tool call itself instead:

  • Pass the acting user’s identity and scope through to every tool call, and check it server-side on every invocation — not just once at session start. The MCP server, not the agent, is the enforcement point.
  • Separate read and write tools explicitly, and require a higher bar (explicit confirmation, narrower scope, or both) for anything that mutates state versus anything that only retrieves it.
  • Treat tool results as untrusted input for permission purposes, not just for correctness — a tool that returns content from an external or user-controlled source shouldn’t be able to smuggle instructions that get treated as if they came from the operator.

The mental model that holds up: the agent is not a trusted principal, the tool call is the thing you authorize, every single time it happens.

Versioning Tools Without Breaking Agents Already Running

MCP tools change — you’ll rename parameters, split one tool into two, tighten a schema. The hard part is that unlike a typical API client, an agent mid-task has already reasoned about the tool’s old shape and may be holding onto assumptions about it across a long-running session.

  • Add before you remove. Introduce a new tool version alongside the old one and let both exist for a deprecation window, rather than changing an existing tool’s contract in place.
  • Make deprecated tools fail loud and useful, not silent. A deprecated tool should still work but return a clear message pointing to its replacement, so an agent that calls it mid-task can adapt rather than getting a cryptic schema mismatch.
  • Version in the tool name or a discoverable field, not just in a changelog nobody reads. search_orders_v2 sitting next to search_orders costs you nothing and saves you from ever needing to coordinate a flag-day cutover across every agent that might be running against your server.

Key Takeaways

  • Write tool descriptions around when a model should choose the tool, not what it does internally, and explicitly rule out sibling tools that could plausibly match the same request.
  • Constrain parameters with enums, patterns, and required fields wherever the domain allows it, instead of leaving room for the model to guess plausible-sounding values.
  • Enforce permissions per tool call on the server side, treating the agent as an untrusted principal rather than inheriting a blanket authorization from the session.
  • Version tools by adding new ones alongside old ones with a deprecation window, rather than changing a live tool’s contract out from under agents already mid-task.