Designing MCP Tools

10 min read

C2
Deep Dive · MCP

Designing MCP tools for the agent workflow (not for a REST client) is the biggest lever between a five-tool server that works and a fifteen-tool server the agent ignores.

The tool description is the entire prompt the agent sees; if it reads like an OpenAPI note, the agent will pick a different tool. Tool-selection accuracy is not a model problem — a documented 24-percentage-point drop with tool explosion — but a design problem, and description phrasing changes which tool an agent picks more than model choice does. Get this right on the first five tools and the server disappears from your debugging queue.

STEP 1

Descriptions are prompts, not docstrings.

The single most common misread of MCP is treating the tool description as documentation. It is not. The description is text the model reads during selection, alongside every other tool's description, competing for the model's attention. Structurally it is indistinguishable from an instruction in a system prompt: same tokens, same attention budget, same influence on what the model does next. A "Retrieves user information from the database" line is a docstring aimed at a future maintainer; it tells the model almost nothing about when to reach for the tool and, worse, tells it nothing about when not to. If a sibling tool has a description that reads like an instruction, the sibling wins the selection every time.

The shape that works is short, verbal, and instructional. Start with a verb. Name the use case in the reader's terms, not the API's. State a when-and-when-not so the model has an explicit rule to apply. End with one usage example the model can pattern-match against. The general tool-design principles deep-dive covers the underlying rationale for treating tool metadata as prompt surface; the MCP specifics come down to writing for the agent that will read the text, and being explicit about the negative cases most authors leave implicit — a discipline the agent-facing tool docs deep-dive develops in general and MCP's tools/list surface makes load-bearing.

The rewrite is easier to feel than argue about. "Retrieves user information from the database" becomes "Get a user by id when you need their email, role, or account status. Do NOT use for listing users (use list_users). Example: get_user(id='u_123')." The second version wins in production because it gives the model both a positive trigger and a negative one. Any place two descriptions could be confused with each other, disambiguate in place: cross-references inside descriptions are the cheapest prompt engineering the protocol supports, and they turn near-neighbour tools from competing candidates into a coordinated workflow.

One detail of revision 2026-07-28 changes how long a bad description costs you. Every tools/list result now carries ttlMs and cacheScope next to the tools, and an absent ttlMs means the client assumes 0. Whatever number you put there is how long the old wording keeps winning selections after you have fixed it: the prompt you are writing has a cache lifetime, and you are the one who set it.

STEP 2

Granularity: the fine-tool tax and the coarse-tool blast radius.

Every server converges on a granularity question, and both extremes fail in opposite ways. Fine tools multiply round-trips and inflate the tool list every host has to render into the model's context on every turn. Coarse tools bundle decisions inside the server, hiding behaviours the model can no longer choose between, and a single failed call has a larger blast radius because the server was going to do several things atomically.

The signal against very fine granularity is the tool-selection accuracy curve. Anthropic's Tool Search Tool motivation documented a roughly 24-percentage-point drop in selection accuracy as tool counts grew into the hundreds — the model's ability to pick the right tool degrades as the list length grows, and it degrades faster than the list. The tool granularity deep-dive works the trade-offs in general; the MCP-specific rule of thumb, drawn from the same Bloomberry survey the previous essay cites, is to prefer five to eight tools per server. If you have fifteen, you probably have two servers pretending to be one.

The against-coarse signal is subtler and shows up in traces. When one tool packs several conceptual operations together — an execute tool that takes an action name plus a payload — you lose the ability to read the trace and know which operation ran. You also lose the annotation system: destructiveHint is a per-tool flag, so a tool that sometimes writes and sometimes reads has to be marked destructive always, which means every call gets a consent prompt, which means the host UX degrades for the read cases. Split along the destructive-vs-idempotent boundary first, then along the domain-object boundary — one tool per (object, action) pair, where the action is coarse enough to feel like a task the user would name aloud.

STEP 3

Search-then-fetch, and other patterns that survived first contact.

The single most consequential MCP tool pattern is search-then-fetch, and its rationale is worth stating cleanly because most tutorials get the trade-off backwards. A naive content tool takes a query and returns matching documents in full. In one call the model has the material it needs; this looks efficient. The failure mode is that the model has no way to decline the material it did not need. A single call can push tens or hundreds of thousands of tokens into the loop, and no primitive inside the turn lets the model give any of it back. The context is spent, and every subsequent tool call is more expensive because the input length has grown.

Split the same operation into two tools — search(query) → [{id, summary}] and fetch(id) → full_content — and the model chooses which documents actually deserve the full read. The extra round-trip is cheap; the context savings are enormous; and the trace becomes readable because the two-step decomposition matches how a human would explain what happened. The pattern was named in Microsoft's Learn MCP post-mortem and is now near-universal for content servers, but the underlying principle generalises: any tool that might return a large payload should have a summary-and-id form and a full-payload form as two separate tools, not one polymorphic tool with a verbose flag.

Two adjacent patterns follow. Paginated reads: a tool that could return an unknown number of results should take a cursor and return a page plus a next-cursor, so the model controls how much of the result set enters context one page at a time. Structured filter tools kept separate from fetch tools: a list_incidents(status, since) that returns id + one-line summary belongs distinct from a get_incident(id) that returns everything. Merging them into a query_incidents with a "detail level" argument reintroduces the polymorphism the split was meant to remove.

That cursor is now the model's problem, which makes it yours. Revision 2026-07-28 removed per-connection state and put nothing in its place: state that spans requests "MUST be referenced by an explicit identifier the client passes on each request," and the spec says plainly that "an open connection, such as a STDIO process, is not a conversation or session." Your continuation handle — a page cursor, a search id, a draft token — is therefore an ordinary tool result that the model carries back to you as a tool argument. It is model-visible, model-chosen, and by the time it returns it has sat in a context window next to whatever untrusted text the agent read along the way. Design it as an attacker-influenceable input: unguessable rather than sequential, bound to the authorization that produced it, and validated on every use. "Same connection" no longer implies "same caller," so a handle that encodes only what to return and not who may have it is an access-control bug waiting for a prompt injection to find it. The 2026-07-28 revision essay covers the removal and the rest of the migration in full.

STEP 4

Breadcrumbs: designing tools that let the agent converge.

Tools that only report facts leave the agent to figure out what to do next; tools that report facts and suggest the next move let the agent converge in fewer turns. A search tool that returns [] forces the model to invent a next step — try a different query, give up, ask the user. A search tool that returns { "results": [], "hint": "No matches. Try broadening the query, or add a category filter with list_categories()." } tells the model what to try next, and the trace collapses from three-or-four exploratory turns into one.

Every tool response should end with a next-action hint or an explicit "you're done" signal. Ambiguity between the two is where agent loops go long: the model keeps calling, hoping for a stopping signal the tools were never designed to emit. Explicit terminators — a field like done: true, or a hint that begins "This is the final result" — dramatically shorten conversations that would otherwise loop. The other side of the same rule is that tool descriptions should reference each other: search's description mentions fetch as the natural follow-up; list_incidents's mentions get_incident. This turns a flat manifest into a directed graph the model can traverse. The practitioner phrase — leave breadcrumbs so agents converge — is exactly right: a trace of a well-breadcrumbed server reads like a story; a trace of a breadcrumbless one reads like a random walk.

{
  "results": [],
  "hint": "No matches for 'quarterly-report-q4'. Try: (a) broaden the query,\n  or (b) call list_categories() to see valid filter values,\n  or (c) confirm with the user that the report exists.",
  "done": false
}

Breadcrumbs are a convention. There is now a protocol-level version of the same idea for the case where the server genuinely cannot finish without something from the host, and it is worth keeping the two apart in your head. MRTR — Multi Round-Trip Requests — has the server return resultType: "input_required" with an inputRequests map and an opaque requestState; the client then retries the original call with inputResponses keyed identically, under a different JSON-RPC id. It is permitted only on tools/call, resources/read and prompts/get. The design line is clean: input_required means "I cannot answer until something is fetched for me"; a hint field means "I answered, and here is what to try next." Using a round-trip to nudge the model is a category error, and using a hint where the server actually needs a credential leaves the agent guessing at a question it was never asked.

Because requestState leaves your server and comes back through the client, it MUST be treated as attacker-controlled — the same rule as the continuation handles above, with higher stakes. Integrity-protect it with an HMAC or AEAD if it influences authorization, reject state that fails verification instead of falling back to a default, and embed a principal, a TTL, and an identifier for the originating request so a replayed blob cannot be pointed at a different call. And know what you are building on before you build: Sampling, Roots and Logging — the things MRTR carries — are themselves marked Deprecated in 2026-07-28. The policy puts the earliest removal at "the first revision released on or after 2027-07-28," which is the date removal becomes eligible, not a date on which anything disappears; nothing has actually been removed under the policy yet. Deprecated-but-present is a reason to keep a fallback path, not a reason to design around the feature.

STEP 5

Concrete: rewriting a REST-mirroring server into an agent-shaped one.

The failure mode of servers built by wrapping an existing API is that they mirror the REST surface one-for-one. If the API has GET /users, GET /users/{id}, and PUT /users/{id}, the naive MCP server exposes three tools with names like list_users, get_user, and update_user that take the REST parameters verbatim. The tools work. They also do not help the agent, because the agent's task is rarely "call the REST endpoints in order" — it is "find the right user and update their role," which touches the same endpoints but wants a different shape.

# Before: REST-mirroring — five tools, each shaped like an HTTP verb.
@mcp.tool
def list_users(limit: int, offset: int) -> list[User]: ...
@mcp.tool
def get_user(id: str) -> User: ...
@mcp.tool
def update_user(id: str, user: User) -> User: ...

# After: agent-shaped — three tools, each named for the task, each returning
# the shape the next step of the workflow actually needs.
@mcp.tool
def find_user(query: str) -> list[UserMatch]:
    """Find users by name, email, or id. Returns id + one-line summary.
    Call get_user_details(id) for the full record before updating."""
    ...
@mcp.tool
def get_user_details(id: str) -> UserDetails:
    """Return the full user record (profile, roles, recent activity).
    Use after find_user() when you have the id and need everything."""
    ...
@mcp.tool
def update_user(id: str, changes: UserChanges) -> UserDiff:
    """Apply a partial update. Pass only fields that change.
    Returns a before/after diff so the caller can verify the write."""
    ...

Four things change together. The names describe agent tasks — "find," "get details," "update" — rather than REST verbs. Each description names the workflow neighbour so the model can traverse. The update_user input is UserChanges — the delta — not the whole record, which makes intent explicit and prevents the accidental clobbering pattern where the model round-trips a fetch, mutates one field, and writes back everything else unchanged. And update_user's return is a diff, not the new record, which puts the important information — what did I just change — at the front of the model's next-turn context.

The through-line back to the previous essay: the five-tool median server works because those five tools were chosen for the agent's tasks. A fifteen-tool server that mirrors a REST surface has the same underlying operations available and works worse, because the tools do not match the shape of the work. Design for the agent's workflow first, translate to the underlying API second, and the tool count usually falls out at five to eight without anyone having to enforce a rule.