Agent Engineering

Tool and MCP Design: Building the Surface an Agent Drives

I was wrong about MCP — it's a client, not a context pipe. What that mistake cost, and how to design a tool surface an agent can actually reason over.

Article 4 of 712 minIntermediate
Agent Engineering
Key Takeaway

- I spent months treating MCP as a smarter way to feed a model context. It is a client — a front door to your product on the same footing as your API, CLI, and mobile app — and that wrong model made me build the wrong things. - A tool is not a data-delivery mechanism. It is a capability with a price the model pays on every single turn, in resident schema tokens and in selection risk. - Capability does not scale with tool count. Twelve narrow tools and three well-shaped ones can expose identical power, and the twelve will fail more often — on selection, not on capability.

I Filed MCP Under the Wrong Category for Months

For most of a year I described MCP to other architects as a mechanism for supplying dynamic context, resources, and prompts to a model. Context pipe. Better plumbing between our data and the LLM.

I designed accordingly. My MCP work looked like an ETL problem: which resources do we expose, how fresh, how do we chunk them. The tools I wrote were thin accessors around things the model might want to know, named after the internal services they wrapped, returning whatever the underlying endpoint returned. I was building a pipe, and a pipe is judged on throughput.

A recent assignment broke that. I watched a user run a full workflow through MCP with no training and no product tour. She described what she wanted in one sentence. The agent chose tools, sequenced them, recovered from a bad first attempt, and finished. She never saw our UI and never learned our nouns. That told me what had actually happened: we had not improved the model's context, we had shipped a new client for our product — and I had not designed one thing about it on purpose.

Two mental models, two different jobs:

  • Context pipe. Push the right data at the model. Optimize for coverage and freshness. Tools are getters. Success is "the model knew the thing."
  • Client. Design a surface something else drives. Optimize for legibility, safety, and recoverability under a driver you cannot predict. Tools are controls. Success is "the driver finished the workflow without help."

The second model makes you an API designer for a consumer with excellent language skills, no memory between sessions, no ability to read your source, and an eagerness to guess when your interface is ambiguous. Every decision below falls out of taking that seriously — including the architect's question, which stops being "should we add MCP?" and becomes "which workflows earn a front door that is spoken to instead of clicked, and what do we harden first?"


A Tool Is a Priced Capability, Not a Data Endpoint

Every tool you register is paid for twice, and the pipe model hides both charges.

You pay in resident context. Tool schemas sit in the request on every turn, not only the turn that uses them. Illustrative arithmetic, inputs shown: 12 schemas at roughly 90 tokens each is about 1,080 tokens per turn, and a 30-turn run pays that 30 times — call it 32k tokens spent describing tools before the agent reads a line of your code. Three schemas at roughly 140 tokens is about 420 per turn, or 13k over the same run. That gap buys nothing if the twelve expose the same capability as the three, and it eats budget you needed for real work — the subject of context engineering for long-running agents.

You pay in selection risk. With n tools, every turn is an n-way classification performed from descriptions alone. Overlapping tools concentrate that risk rather than splitting it, because the model has no way to distinguish two things you described in similar words.

Both are instances of the cost frame this pathway runs on, argued in full in multi-agent architecture as premature optimization. Tools are where those costs get paid most avoidably, because a schema is cheap to fix and nobody reviews schemas.

What makes a good agent tool? A good agent tool names one capability in the model's own vocabulary, states in its description when not to use it, takes narrow parameters with enums instead of free strings, is idempotent because retries are guaranteed, returns a bounded and stable shape, and fails with an error message that names the next call to make.


Six Schema Rules and the Failure Each One Prevents

1. Name for the model's vocabulary, not your codebase's

invoke_document_retrieval_service_v2 is a name from your service registry. search_docs is a name from the model's training distribution. It has seen a million functions called search and yours zero times.

Prevents: the tool never getting picked. Silent, and the worst failure mode available, because your logs show a task that failed for "no reason" while a good capability sat unused.

2. Say when not to use the tool

The highest-leverage line in most schemas, and missing from almost all of them. Descriptions say what a tool does. They almost never say what it is not for.

description: "Search file contents by text or regex. Returns matching paths and
line numbers. Use when you do not know which file holds something.
Do NOT use to read a file you already have the path for — use read.
Do NOT use to find files by filename — pass a name pattern to read's glob."

Prevents: the wrong-tool-then-recover loop. Without the negative clause, an agent that already knows a path will still search for it, burn a turn, get 40 matches, and read the file anyway. Two turns and a polluted context window for one operation.

3. Narrow, non-overlapping parameters; enums over free strings

A mode: string parameter is an invitation to invent a value. mode: "fuzzy" | "exact" | "regex" is a closed set the model cannot get creatively wrong. Same for sort orders, statuses, environments, and every field where you have a fixed list and wrote string because it was faster.

Prevents: validation-error round trips — the cheapest thing to eliminate and the most common thing in a real trace.

4. Idempotency, because retries are guaranteed

Not likely. Guaranteed. Agents retry on timeouts, ambiguous errors, partial results they misread as failures, and loop-controller instructions. If create_ticket is not idempotent you will ship duplicate tickets. Take an idempotency key, or make the operation convergent: set_labels(["a","b"]) instead of add_label("a") called twice.

Prevents: duplicate side effects, the one class of agent bug your users see before you do. Retry behavior belongs to loop engineering; making the retry safe is the tool's job.

5. Return shapes: bounded, stable, no ambient pagination

Cap result size in the tool, not in the prose of the description — a tool that can return 400KB of JSON will, and it will evict the plan the agent was working from. Keep keys stable, because the model learned your shape in turn 3 and will assume it in turn 19. Never paginate implicitly: if you truncated, say so in the payload alongside the exact call that gets the rest.

Prevents: context eviction, and phantom completeness. The second is nastier. An agent that receives 50 of 300 results with no truncation marker concludes it has seen everything and reports a confident wrong answer.

6. Write errors for a model to act on

The most under-covered surface in tool design, and where the client model earns its keep. Your error string is not a log line. It is a prompt, read at the exact moment the model is already off-track and about to spend tokens guessing.

ENOENT: no such file or directory, open 'src/db/querryRaw.ts' tells a human everything and a model almost nothing actionable. It cannot tell whether the path was wrong, the file was deleted, the working directory differs, or the operation is retryable. So it guesses: retries the same path, invents a plausible alternative, then starts listing directories one at a time.

Compare:

{
  "error": "not_found",
  "retryable": false,
  "message": "No file at 'src/db/querryRaw.ts'. Closest names in src/db/: query-raw.ts, query.ts, migrate.ts.",
  "next": "Call read({ path: 'src/db/query-raw.ts' }) or read({ path: 'src/db' }) to list all 9 files."
}

Four properties, all load-bearing:

  • A stable machine code (not_found) so the model pattern-matches across turns instead of parsing English.
  • An explicit retryable flag. A model facing ambiguous failure retries by default. retryable: false converts a wasted turn into a corrected one; retryable: true, retry_after_ms: 2000 converts a bailout into a success.
  • The truth you already know. You performed the lookup that produced ENOENT and have the sibling filenames in memory. Withholding them so the agent rediscovers them with three list_dir calls is the most expensive politeness in agent engineering.
  • A concrete next call, written as a literal invocation with real arguments. Not "try listing the directory."

Two things not to do. Do not return stack traces: hundreds of tokens, no next action, and a strong pull toward debugging your tool instead of doing the task. And do not lie by omission on permissions. Bare permission denied produces an agent that tries four variations of the same forbidden thing; permission denied: write access to /etc is not granted, this path is outside the workspace root ends the attempt in one turn.


The Tool-Count Problem: Fewer Decisions, Not Fewer Capabilities

The usual advice when an agent starts mis-selecting is "remove some tools." Wrong axis. You do not need fewer capabilities, you need fewer decisions.

The same file-manipulation capability, twice. Twelve narrow tools:

// 12 tools. Every turn is a 12-way choice, and four pairs are genuinely ambiguous.
const narrow = [
  'read_file', 'read_file_lines', 'read_file_head',
  'search_text', 'search_regex', 'search_symbol', 'find_file_by_name',
  'list_dir', 'list_dir_recursive',
  'write_file', 'patch_file', 'append_file',
] as const;

// The failures this shape produces, in order of how often I have seen them:
// - search_text vs search_regex vs search_symbol: the model cannot know which
//   one indexes symbols, so it tries two and pays for both.
// - write_file vs patch_file: it reaches for write_file and clobbers the file.
// - read_file vs read_file_lines: it reads whole files it only needed 30 lines of.

Three shaped tools, same capability:

import type Anthropic from '@anthropic-ai/sdk';

const read: Anthropic.Tool = {
  name: 'read',
  description:
    'Read a file, a line range of a file, or list a directory. Use when you ' +
    'know the path or a filename pattern. Do NOT use to search file contents.',
  input_schema: {
    type: 'object',
    properties: {
      path: { type: 'string', description: 'Repo-relative file or directory path.' },
      lines: { type: 'string', description: 'Optional range, e.g. "40-120".' },
      glob: { type: 'string', description: 'Optional filename pattern, e.g. "**/*.test.ts".' },
    },
    required: ['path'],
  },
};

// search: { query, mode: 'text' | 'regex' | 'symbol', path?, max_results? }
//   -> one tool, one decision; the ambiguity moves into a closed enum.
// edit:   { path, patch, expect_hash? }
//   -> patch-only by design. There is no clobber affordance to pick wrongly.

Three moves get you from twelve to three, and none of them drops a capability:

  1. Consolidate overlapping tools. If two tools answer the same question in different formats, they are one tool with a format parameter.
  2. Push sub-capabilities into parameters. read_file_lines was never a separate capability. It was read with a range.
  3. Gate the rare stuff behind discovery. Tools used in under 5% of runs do not deserve permanent residency in every request. Expose one list_capabilities tool that returns their schemas on demand, and let the model pull them when it needs them.

Notice what the edit signature does: by having no whole-file-write mode, it removes a wrong choice from existence rather than warning against it. That is the most durable form of tool design. A capability the model cannot select incorrectly needs no description discipline at all.

Working code for both configurations, plus the runner, is in github-repo/agent-engineering/02-tools-mcp/.


BENCH-1: Twelve Tools Versus Three

The benchmark task for this pathway is fixed: a 40-file TypeScript service calls a deprecated db.queryRaw() in seven places, and the agent must migrate every call site to the parameterized db.query(), add no new queryRaw call sites, and leave the suite green. Scored per run on input tokens, output tokens, wall-clock seconds, and pass/fail, reported over 20 runs.

Both configurations here can complete the task. That is the point of the comparison. Neither is capability-limited.

ConfigurationPass rate (20 runs)Median tokensp95 wall clock
12 narrow tools
3 shaped tools
<!-- BENCH:TBD — fill from github-repo/agent-engineering/02-tools-mcp/results.md after running -->

What I expect this to show, stated before the numbers exist so it is falsifiable: the pass-rate gap will be driven almost entirely by selection errors, not capability gaps. Specifically, the twelve-tool failures should cluster on write_file chosen over patch_file (whole-file rewrites that drop unrelated code and break tests) and on redundant search calls that inflate token count without advancing the task. Median tokens should be higher for twelve tools for two independent reasons: the resident schema tax on every turn, and the extra turns spent recovering from wrong picks. If the twelve-tool configuration matches on pass rate and only loses on tokens, my argument is half wrong and I will say so in the results file.


Where MCP Actually Fits, Given the Client Model

MCP standardizes the wire: how a client discovers what a server exposes, how it invokes a tool, how resources and prompts are named and fetched, how transport and capability negotiation work. That is genuinely valuable — it means one well-built server is reachable from any compliant host without bespoke glue per assistant.

What MCP does not standardize is everything this article is about. It has no opinion on how many tools you expose, whether their names are legible to a model, whether your descriptions say when not to use them, whether your operations are idempotent, whether your return shapes are bounded, or whether your errors are actionable. The protocol will faithfully transport a terrible tool surface. Adopting MCP is a distribution decision. Designing the tool surface behind it is the engineering, and it is unchanged whether you ship over MCP, a raw SDK loop, or a framework. For getting a server configured and running, MCP servers explained, with a practical setup covers that ground and this article does not repeat it.


Your Tool Surface Is Also Your Permission Boundary

One consequence of the client model that is easy to miss: the set of tools you register is the authorization scope of the agent. Not your IAM policy, not your review process. Whatever a tool can do, the agent can do, on a plan it composed itself from a sentence a user typed.

That has two follow-on implications, and both are covered elsewhere rather than re-argued here. The mechanics of the permission contract — tiering tools, gating destructive operations, deciding what requires confirmation — belong to the harness, and are the subject of agent harness design. The organizational side, blast radius and what you let an agent merge without a human, is argued in autonomous PRs and letting agents merge safely.

The tool-design point is narrower and worth stating on its own: design the destructive capability out of the schema where you can, instead of policing it at runtime. An edit tool that only accepts patches cannot destroy a file. That constraint holds even when the prompt is hostile, the model is confused, or your permission check has a bug.


What to Do Monday

Open your tool definitions and count them. For each one, answer three questions: would a model that has never seen my codebase pick this name for this job, does the description say when not to use it, and would the error message it returns on the most common failure tell a model what to call next? Then find your two most-overlapping tools and merge them into one with an enum. That is a half-day of work with a measurable effect on your token bill, and it is the highest return per hour available anywhere in an agent stack.

The rest of this pathway's costs — the loop, the graph, the evals — are argued from the same starting point in what agent engineering actually is.


FAQ

How many tools should an AI agent have?

Fewer than you think, and the number matters less than the overlap. Aim for a set where no two tools could plausibly answer the same request. In practice most coding and workflow agents run well on three to eight well-shaped tools; past roughly a dozen, selection errors and resident schema cost both climb. Consolidate before you cut capability.

What makes a good tool description for an LLM?

One sentence on what the tool does, then an explicit statement of when not to use it and which tool to use instead. The negative clause is the highest-value line, because it prevents the wrong-tool-then-recover loop that costs two turns per occurrence. Describe the return shape briefly, and name any hard limits like truncation thresholds.

Should tool errors be human-readable or machine-readable?

Both, in the same payload. Return a stable machine code, an explicit retryable flag, a plain-language message containing facts you already know (nearby filenames, valid enum values), and a concrete next call written as a real invocation with real arguments. Skip stack traces entirely — they cost hundreds of tokens and suggest no next action.

Is MCP a replacement for RAG?

No. They solve different problems. RAG retrieves relevant text into a context window; MCP is a protocol for a client to discover and invoke capabilities on a server, which may include retrieval among many other operations. Treating MCP as a retrieval mechanism is the exact mistake this article opens on. You can expose a RAG search as an MCP tool.

Do agent tools need to be idempotent?

Yes, for anything with a side effect. Agents retry on timeouts, ambiguous errors, misread partial results, and loop-controller instructions, so a non-idempotent write will eventually fire twice. Accept an idempotency key, or design the operation to converge — set a full list rather than appending one item. Read-only tools are exempt, but everything that mutates is not.