Skip to main content

Tool Design Patterns and Anti-patterns

This guide covers principles for designing tools that an LLM selects correctly and uses with little context. It is design theory that builds on the discussions of the context problem and the comparison with CLI. The protocol-level specification of tool definition and execution is in Tools, and server implementation is in Building MCP Servers. This document focuses on what unit to design, and how to write it, so that the model chooses without confusion and uses it safely.

1. What This Document Covers​

  • The anti-pattern of turning an API into MCP as is
  • Task-oriented tools
  • How to write descriptions
  • Argument design
  • Compensating for the lack of composability
  • Controlling output size
  • Errors an LLM can self-correct from
  • Safe design of destructive operations
  • Idempotency and state handles
  • Consideration for prompt caching

The anti-pattern of turning an API into MCP as is​

Automatically generating one tool per endpoint from an existing REST API or OpenAPI definition is easy, and the first demo works. But this is the pattern to avoid most in MCP design. The Thoughtworks Technology Radar places this naive API-to-MCP conversion in Hold. What this assessment targets is a design failure, not the protocol.

There are three problems.

  • Definitions bloat: Forty CRUD tools are a generated API client, not an interface for an agent. Tool definitions stay resident in the context whether they are used or not. For the effect on context, see The Context Bloat Problem and Mitigations
  • Selection accuracy drops: With many tools of similar names and descriptions, the model cannot pick the right one. The model cannot tell which of get_user, get_user_by_email, list_users, and search_users to use
  • One job needs many calls: A user's request, such as "group last month's unpaid invoices by customer", spans several endpoints. At REST granularity, the model carries intermediate data and makes many round trips. Each round trip adds tokens, time, and chances to fail

An API is something the server uses internally, not something to show the model. What you show the model is the unit of work that users actually ask for.

Task-oriented tools​

Design tools around the unit of work a user asks for, not around API endpoints. Inside one tool, perform several API calls, filtering, and formatting, and return only the result of that task to the model.

AspectPer endpointTask-oriented
Number of toolsDozensA handful to a dozen
Number of callsMany per requestOne to a few
Intermediate dataPasses through the model's contextProcessed inside the server
DescriptionTechnical API specificationThe user's purpose in their own words

The following shows the same work designed in two ways.

Per endpoint: list_customers, get_customer, list_invoices, get_invoice, list_payments, ...
Task-oriented: find_overdue_invoices (returns unpaid invoices grouped by customer)
get_customer_account_summary (returns a customer's billing, payments, and contacts in one call)

Going task-oriented reduces the number of tools and the size of the definitions and makes the model's selection more accurate. On the other hand, a single do-everything tool (call_api(path, method, body)) avoids the selection problem but leaves arguments too free to validate or confirm, and it contradicts least privilege. As a guide for granularity, use a unit you can explain in the user's own words.

Work backward from the list of requests users actually make to decide which tools to build. Not starting from the list of existing APIs is the first fork in the design.

How to write descriptions​

The model reads the tool's name and description and each argument's description to decide which tool to use and when. Some argue that the quality of descriptions matters more for selection accuracy than the number of tools. Write these three things.

  1. What it does: start with a verb and give the result in one sentence
  2. When to use it: which requests should lead to choosing it
  3. When not to use it: how it differs from similar tools. Name the neighboring tool
Bad: "Gets invoices"
Good: "Returns unpaid invoices past their due date, grouped by customer. Use it for requests like 'which customers need a reminder' or 'list of overdue accounts'.
For the details of one invoice, use get_invoice."

Other guidelines.

  • Put the important information first. Some clients truncate descriptions (Claude Code truncates at 2,048 characters by default), so the end of a long description may not arrive
  • Do not make it too long. The description makes up most of the definition's size. Keep to what is needed for selection, and convey detailed usage in result and error messages
  • Write side effects and constraints in the description. State what the model should know before calling, such as "this incurs charges" or "expires after 24 hours"
  • Also give format examples in argument description ("ISO 8601 date, for example 2026-07-28")
  • title is a display name shown to users and is separate from the description the model reads. Make name 1 to 128 characters of only alphanumerics, _, -, and ., and unique within the server (case-sensitive). Name consistently with a verb and an object (search_orders, get_order, cancel_order)
  • A host that aggregates tools from external servers can see name collisions. On the server side too, avoid names that are too generic, such as search

The description is an instruction the model reads, so you must not embed untrusted external input in it. For attacks that rewrite tool definitions, see AI Agent-Specific Threats.

Argument design​

Arguments are a form the model fills in. Build them so the model rarely gets them wrong and mistakes can be detected.

  • Separate required from optional: Keep required arguments to a minimum. Do not make an argument required if the model could fill it only by guessing. Give optional arguments safe, reasonable defaults (for example, a default limit of 20)
  • Use enumerations: Make arguments with a fixed set of values an enum. With a free string, "pending", "Pending", and "unprocessed" get mixed
  • Declare ranges: Write constraints in the schema with minimum, maximum, maxLength, and pattern. The SDK validates against the schema, so the handler receives validated values
  • Keep them flat: A schema with deeply nested objects or heavy use of oneOf / anyOf is hard for the model to build correctly. Keep arguments as flat as possible
  • Accept names rather than IDs: If the tool can be called with the user's words (a customer name, a project name), resolve them inside the server. This removes the need for the model to first search for an ID. If it is ambiguous, return candidates and let the model choose
  • Tools with no arguments: Make the inputSchema { "type": "object", "additionalProperties": false } (the form the specification recommends). null is not allowed
  • Schema dialect: Without $schema, it is JSON Schema 2020-12. Using 2020-12 is recommended

Typing output with outputSchema: A tool can declare the structure of its result with outputSchema. If it does, the server must return structuredContent that conforms to it (MUST), and clients are recommended to validate the result (SHOULD). structuredContent can be any JSON value (an object, array, string, and so on). For backward compatibility, a tool that returns structured results is recommended to also put the serialized JSON in a text block of content. With an outputSchema, a client can generate a typed API for Code Mode, and the model can handle only the fields it needs, which also saves context.

Mirroring to headers (x-mcp-header): With Streamable HTTP, you can mirror some arguments to Mcp-Param-{name} headers. This lets a gateway route or restrict by values such as region or tenant without parsing the body. Add it to a property's schema as "x-mcp-header": "Region". The target must be a string, integer, or boolean (number is not allowed), and reachable from the schema root through properties only. Headers are visible to network intermediaries, so never add it to arguments that hold passwords, API keys, tokens, or personal information.

Compensating for the lack of composability​

In a CLI, you can narrow results on the spot by chaining grep, sort, head, and jq with pipes. A tool result in MCP goes straight into the model's context, so there is no such means of composition. Tool arguments fill that gap. Provide the following arguments, which correspond to the pipe, on the server side.

CLI pipeTool argumentExample
grepFilterstatus, customer, since, query
sortSortingsort_by, order (enum)
headResult limitlimit (set a default and a maximum)
cut / jqField selectionfields (an array of enum). Return only the columns needed
wc -lAggregationcount_only, or a dedicated aggregation tool

The key is to do the narrowing on the server side. A design where the model receives 1,000 rows and then narrows them in its head wastes tokens and adds mistakes. If the aggregations and transformations vary, you can also declare an outputSchema so that clients can narrow in Code Mode (writing code and running it in a sandbox).

Controlling output size​

Every tool result goes into the model's context. Control output size at design time.

  • Pagination: Provide limit and cursor, and put in the result a nextCursor indicating the next page and the total count (if known). Let the model know that "the result was cut off"
  • Keep the default count small: Make the default limit small (10 to 50), decide a maximum, and enforce it on the server. For a request above the maximum, round it down to the maximum and say so, instead of returning an error
  • Return summaries: For lists, return the minimum fields enough to choose from, such as ID, name, and status, not every detailed field. Let the model fetch the details with a separate tool (get_order)
  • Return large data by reference to a resource or file: For large content such as logs, documents, and images, do not embed it in the result. Return a URI with resource_link, and let the client fetch it with resources/read when needed. A resource link does not necessarily appear in the result of resources/list. For small content, you may return an embedded resource (resource)
  • State truncation explicitly: When you cut output, say in the result that you did and how to get the whole (the next page, the filter arguments). If you cut silently, the model believes the result is complete

Images and audio enter the result as Base64, so they are large and consume many tokens. Return them only when needed.

Errors an LLM can self-correct from​

There are two kinds of tool errors. This distinction decides whether self-correction is possible.

KindExamplesHow to return
Protocol errorUnknown tool, malformed request that does not satisfy the schema, server failureA JSON-RPC error (for example, -32602). Hard for the model to recover from
Tool execution errorAPI failure, input validation error, business rule violationA successful response with isError: true

Make failures the LLM can fix by adjusting arguments and retrying the latter kind. The specification says clients should pass tool execution errors to the model to enable self-correction. Include these three things in the message.

  1. What is wrong: which argument was rejected, and why
  2. How to fix it: the allowed values, formats, and ranges. "The usable values are pending, shipped, and cancelled"
  3. The current situation: if needed, information for fixing it, such as the current date or candidate valid IDs
Bad: "Invalid input"
Good: "Departure date 2026-01-05 is not allowed. The departure date must be in the future. The current date is 2026-10-13."

A generic error message leaves the model unable to fix it and makes the retry wasted. Conversely, a message must not contain internal stack traces, connection strings, or other users' information. The body of an error is also output the model reads, and it can be a channel for leaking information. For a missing permission (403), you may also choose to return the same shape as "not found" so as not to reveal whether something exists.

Safe design of destructive operations​

A tool with an operation that cannot be undone, such as deleting, transferring money, sending, or publishing, is protected in several layers.

1. Declare its nature with annotations: A tool can carry annotations that describe its behavior, such as readOnlyHint and destructiveHint. They help a client decide how to display the tool and what confirmation policy to apply. However, the specification says clients must not trust annotations unless they come from a trusted server (MUST). Annotations are therefore not a safety mechanism in themselves but an aid. Always implement restrictions on the server side too.

2. Build confirmation material with dry-run: Give a destructive operation an argument such as dry_run (or a preview-only tool) and have it return what would happen (the number of items deleted, the targets, the amount) without executing. The model shows that to the user and moves on to the real execution after approval.

3. Confirm just before execution with elicitation: A server can ask the user for confirmation just before execution. Make the response to tools/call an InputRequiredResult and present the content to confirm in the form mode of elicitation/create. The client collects the user's input and resends the original request (MRTR). What matters is that the confirmation is enforced by the server rather than left to the model's judgment. Note the following.

  • A client does not necessarily support elicitation. For a client that has not declared support, return MissingRequiredClientCapability (-32021), or provide an alternative path (allow only dry-run and refuse execution)
  • In form mode, do not ask for sensitive information such as passwords, API keys, tokens, or payment credentials. Such input belongs in URL mode, out of band
  • requestState is a value that passes through the client, so it can be tampered with. Protect it with a signature or encryption, and verify it. For the details of MRTR, see Elicitation and Multi Round-Trip Requests

4. Least-privilege scopes: A single token with all privileges has the largest impact when it leaks. Split read and write into different scopes and decide the required scope per tool. The specification allows the set that tools/list returns to vary with the request's authorization (the granted scopes). You can design it so that a caller with only a read scope is not shown the write tools. But leaving a tool out of the list is not a substitute for authorization. Always validate the scope when the tool is executed as well. Return a missing scope with 403 and insufficient_scope so the client can step up (see MCP Authorization). Also, a list with cacheScope set to public can be shared among callers with different authorization, so make a list that varies per user private.

5. The server's obligations under the specification: Validate all inputs, implement access controls, rate limit tool invocations, and sanitize outputs. Do not let the existence of a client's confirmation UI stand in for these.

Idempotency and state handles​

An operation with side effects can run twice because of a network failure or a client retry. When a stream breaks, the client resends from the start with a new request ID, and to the server it is a different request. Design tools that create, send, or charge so that they are safe to retry.

  • Give tools with side effects a client-generated idempotency_key, and return the first result for a second call with the same key
  • Guarantee "the same content once" with a unique constraint on the store side
  • Prefer "declare the desired state" over "change the state" ("set the stock to 9" instead of "decrease the stock by 1"; "make the member included" instead of "add the member")

2026-07-28 has no protocol-level session. State that spans several calls (a basket, an open browser, a database transaction) is passed as an explicit handle the server issues, as an ordinary tool argument. The (non-normative) guidance in the official specification asks for the following.

  • Verify the owner: A handle is a name, not a capability. Validate the caller's authorization against the handle on every call. For an unauthenticated server, the handle is a bearer token as is, so generate it with sufficient entropy (such as a UUIDv4) and give it a bounded lifetime
  • Opacity: A handle that encodes internal structure invites parsing and guessing. Use an identifier with no meaning
  • State the lifetime: Write the retention policy in the description of the creation tool (for example, "baskets expire after 24 hours of inactivity")
  • Expiry errors: For an expired or unknown handle, return a tool execution error that says so, so the model can recover by creating a new one

For the handle hijacking attack, see MCP Security Best Practices, and for implementation, see Building MCP Servers.

Consideration for prompt caching​

Many model providers cache the start of the prompt (including the tool definitions array). Adding, removing, or reordering tool definitions midway invalidates this cache, and the cache miss can cost more tokens than the definitions you removed. On the server side, observe the following.

  • Return tools in a deterministic order: Return the same order every time if the underlying set has not changed (SHOULD). This lets clients cache the list reliably and raises the prompt cache hit rate. Make sure the order does not wobble depending on map iteration order or the order concurrent work completes
  • Do not change definitions during execution: In 2026-07-28, the set that tools/list returns must not vary per connection or as a side effect of other requests (MUST NOT). The only permitted variation is over time (list_changed) or by the request's authorization
  • Keep changes infrequent: When you do change, signal it with notifications/tools/list_changed. On receiving the notification, a client treats its cache as stale even before the TTL expires
  • Return ttlMs and cacheScope appropriately: If the list is the same for all users, use public and a longer TTL. If it differs per user, use private
  • Do not rewrite descriptions frequently: Putting the current time or changing numbers in a description invalidates the cache every time. Put dynamic values in the result

2. Common Anti-patterns and Improvements​

Anti-patternProblemImprovement
Turning every REST endpoint into a toolDefinitions bloat and selection accuracy dropsConsolidate into the user's task units
A one-line descriptionCannot tell when to use itState the purpose and how it differs from others
Returning huge results as isSqueezes the contextProvide filter arguments and pagination
Generic error messagesThe LLM cannot fix it and retries are wastedReturn the cause and how to fix it
A single token with all privilegesLarge impact if it leaksSplit into scopes per operation
A do-everything toolArguments are too free to validate or confirmSplit into tools per task with narrowed arguments
A tool with side effects that is not idempotentA retry runs it twiceAn idempotency key and a unique constraint on the store side
List order or definitions wobbleThe prompt cache is invalidatedA deterministic order, and no definition changes during execution

3. Common Pitfalls​

Generating everything from OpenAPI and stopping there​

The first demo works, but there are too many tools, the descriptions are mechanical, and the model cannot choose. Treat generation as a rough draft and rebuild it in task units, working backward from user requests.

Pasting the API specification into the description as is​

It is long, technical, and does not say when to use it. Narrow it to what it does, when to use it, and when not to use it.

Leaving the narrowing to the model​

Returning everything and having the model narrow it wastes tokens and leads to misses. Provide filters, sorting, result limits, and field selection as arguments.

Not saying the result was cut off​

If you silently cut at a limit, the model believes the result is complete. Include nextCursor and an explanation such as "top 20 only; specify since to narrow further" in the result.

Returning errors as exceptions or protocol errors​

If you make an input mistake a protocol error, the model cannot recover. Return a fixable failure as a tool execution error with isError: true, with the cause and how to fix it.

Assuming annotations alone make it safe​

Even with destructiveHint, a client does not necessarily trust it, and a malicious server can fake it. Implement scope validation and enforced confirmation on the server side too.

Using leaving a tool out of the list as a substitute for authorization​

Even if you remove write tools from tools/list according to the caller's permissions, a caller who knows the name can still call it. Always validate permissions at execution time too.

Leaving confirmation to the model's judgment​

A description such as "ask the user whether to proceed" is not necessarily obeyed by the model. For operations that need confirmation, enforce it on the server with elicitation or with a split between dry-run and real execution.

Not checking the handle's owner​

An implementation where anyone who knows a handle can operate another user's state is open to hijacking. Match the caller to the handle's owner on every call.

List order changes on every run​

If the tool order wobbles depending on map iteration order or concurrent completion order, the prompt cache is invalidated and the hit rate drops. Sort by name or similar to make it deterministic.

Putting changing values in a description​

If you put the current time or a count in a description, the cache is invalidated every time, and a mechanism that detects definition changes (rug pull detection) produces more false positives. Put dynamic values in the result.

4. Code Examples​

Per-endpoint design versus task-oriented design​

A bad design (per endpoint).

[
{ "name": "get_customers", "description": "Gets customers" },
{ "name": "get_customer_by_id", "description": "Gets a customer by ID" },
{ "name": "get_invoices", "description": "Gets invoices" },
{ "name": "get_invoice_by_id", "description": "Gets an invoice by ID" },
{ "name": "get_payments", "description": "Gets payments" }
]

A good design (task-oriented). The description shows the purpose and how it differs from others, and the tool has filter arguments, enumerations, defaults, and an outputSchema.

{
"name": "find_overdue_invoices",
"title": "Find overdue invoices",
"description": "Returns unpaid invoices past their due date, grouped by customer. Use it for requests like 'which customers need a reminder' or 'list of overdue accounts'. For the details of one invoice, use get_invoice. Results are ordered by amount, largest first, up to limit items. If there are more, next_cursor is returned.",
"inputSchema": {
"type": "object",
"properties": {
"min_days_overdue": {
"type": "integer",
"minimum": 1,
"default": 1,
"description": "Lower bound on days overdue. Default 1"
},
"customer_name": {
"type": "string",
"description": "Part of a customer name. If specified, narrows to that customer only"
},
"sort_by": {
"type": "string",
"enum": ["amount", "days_overdue"],
"default": "amount",
"description": "Sort order. amount is largest first"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20,
"description": "Maximum number of customers to return. Default 20, maximum 100"
},
"cursor": {
"type": "string",
"description": "The next_cursor of the previous result. Specify it to get the continuation"
}
},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"properties": {
"customers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"customer_id": { "type": "string" },
"name": { "type": "string" },
"overdue_total": { "type": "number", "description": "Total amount overdue (yen)" },
"oldest_days_overdue": { "type": "integer" }
},
"required": ["customer_id", "name", "overdue_total", "oldest_days_overdue"]
}
},
"next_cursor": { "type": "string" },
"truncated": { "type": "boolean", "description": "true if cut at the limit" }
},
"required": ["customers", "truncated"]
},
"annotations": { "readOnlyHint": true }
}

The points of this definition are as follows.

  • The description says what it does, when to use it, when not to use it (get_invoice), the sort order, and how to get the continuation
  • Filtering (customer_name, min_days_overdue), sorting (sort_by), a result limit (limit), and pagination (cursor) are arguments
  • The schema constrains with enum, default, minimum, and maximum
  • The outputSchema declares the result structure and whether it was truncated (truncated)
  • readOnlyHint remains only a hint for how a client displays the tool, and the server validates permissions separately

Result size and how to convey truncation​

{
"resultType": "complete",
"content": [
{
"type": "text",
"text": "137 customers are overdue. Returning the top 20 by amount. You can get the rest by specifying the cursor."
}
],
"structuredContent": {
"customers": [
{ "customer_id": "c_001", "name": "Sample Corp.", "overdue_total": 2400000, "oldest_days_overdue": 62 }
],
"next_cursor": "eyJvZmZzZXQiOjIwfQ",
"truncated": true
}
}

Return large content as a link instead of embedding it in the result.

{
"type": "resource_link",
"uri": "reports://overdue/2026-10",
"name": "overdue-2026-10.csv",
"description": "CSV of all overdue invoices (137 customers, about 4,200 rows)",
"mimeType": "text/csv"
}

A self-correctable error​

{
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "sort_by cannot be 'date'. The usable values are amount and days_overdue. To sort by largest amount first, specify amount and call again."
}
],
"isError": true
}
}

Dry-run and confirmation for a destructive operation​

Give a destructive tool a dry_run and default it to true (lean toward the direction where nothing runs without confirmation).

{
"name": "delete_stale_projects",
"description": "Deletes projects not updated for 90 days or more. Deletion cannot be undone. When dry_run is true (the default), nothing is deleted and only the list and count of targets are returned. To execute, first show the targets to the user with dry_run, and after approval call again with dry_run set to false.",
"inputSchema": {
"type": "object",
"properties": {
"older_than_days": { "type": "integer", "minimum": 90, "default": 90 },
"dry_run": { "type": "boolean", "default": true, "description": "When true, nothing is deleted and only the targets are returned" }
},
"additionalProperties": false
},
"annotations": { "destructiveHint": true }
}

Furthermore, to have the server enforce confirmation, request elicitation for a call with dry_run set to false. Return an InputRequiredResult.

{
"jsonrpc": "2.0",
"id": 5,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_delete": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "This will delete 12 projects. This operation cannot be undone.",
"requestedSchema": {
"type": "object",
"properties": {
"confirmed": { "type": "boolean", "description": "Execute the deletion" }
},
"required": ["confirmed"]
}
}
}
},
"requestState": "<signed server state>"
}
}

The client resends with inputResponses and requestState, and the server executes the deletion only when confirmed is true. requestState passes through the client, so sign it and verify it.

A tool that returns a state handle​

{
"name": "create_basket",
"description": "Creates a basket for an order. A basket expires after 24 hours of inactivity. Pass the basket_id in the result to add_item and place_order.",
"inputSchema": { "type": "object", "additionalProperties": false }
}
{
"content": [{ "type": "text", "text": "Created basket bsk_a1b2c3. It expires after 24 hours of inactivity." }],
"structuredContent": { "basket_id": "bsk_a1b2c3", "expires_in_hours": 24 }
}

When an expired handle is passed, return an error the model can recover from.

{
"content": [{ "type": "text", "text": "Basket bsk_a1b2c3 has expired or does not exist. Create a new one with create_basket." }],
"isError": true
}

Design checklist​

ItemWhat to check
GranularityIs it a unit you can explain in the user's request words? Is it not a one-to-one match with endpoints?
descriptionAre what it does, when to use it, and when not to use it written at the start?
ArgumentsAre the required ones minimal? Are there enumerations, defaults, and ranges? Are there filtering, sorting, limits, and field selection?
OutputIs the default count small? Is truncation communicated? Is there an outputSchema? Is huge data returned by reference?
ErrorsAre fixable failures returned with isError: true, with the cause and how to fix? Do they exclude internal information?
Destructive operationsIs there a dry-run? Does the server enforce confirmation? Is the scope validated at execution time?
IdempotencyDoes a retry avoid running twice? Is the handle's owner validated?
CachingIs the tool order deterministic? Are definitions unchanged during execution? Are ttlMs and cacheScope appropriate?

5. Summary​

  • Avoid designing one tool per REST endpoint. Definitions bloat, selection accuracy drops, and one request needs many calls. The Thoughtworks Technology Radar places it in Hold
  • Build tools around the unit of work users ask for (a task), and keep the number of tools and the size of definitions small. But do not make a single do-everything tool
  • In the description, write what it does, when to use it, and when not to use it, starting from the first line. It decides selection accuracy
  • For arguments, keep required ones minimal, declare enumerations, defaults, and ranges, and keep them flat. Type the output with outputSchema
  • In place of CLI pipes, provide filtering, sorting, result limits, and field selection as arguments, and do the narrowing on the server side
  • Keep output small with pagination, summaries, and references to resources, and state when it was truncated
  • Return fixable failures as isError: true tool execution errors that say what is wrong and how to fix it
  • Protect destructive operations with server-side validation, dry-run, confirmation through elicitation, and least-privilege scopes. Annotations are only an aid
  • Make operations with side effects idempotent on the assumption of retries, pass state as handles, and validate the owner every time
  • Return tools in a deterministic order and do not change definitions during execution, to keep the prompt cache intact

References​