MCP Tool Design Best Practices (2026): Names, Schemas, Errors, Tokens

Oct 31, 2026 · 12 min · Ajay Kumar

The first draft of almost every MCP server I review is a faithful mirror of some internal API: one tool per endpoint, parameter names copied from the OpenAPI file, raw JSON back. It works in the sense that every call returns 200. It fails in the sense that the model picks the wrong tool, passes IDs it invented, and burns its context reading payloads nobody needed. MCP tool design best practices are mostly about that gap. The protocol makes it easy to expose a tool; it does nothing to make the tool usable by a model. This post covers what actually matters in 2026: naming, granularity, the token cost of definitions, descriptions and schemas, structured output, errors the model can act on, and how pagination, caching and versioning work under the 2026-07-28 specification. If you need the basics of the protocol first, start with what MCP is.

Why does MCP tool design matter more than the server code?

Because every tool definition is a prompt, and you pay for it on every turn. Anthropic's advanced tool use post (24 November 2025) gives a realistic five-server setup: GitHub, Slack, Sentry, Grafana and Splunk add up to 58 tools and roughly 55K tokens "before the conversation even starts". The same post says Anthropic has seen tool definitions consume 134K tokens internally before optimisation, and that a single Jira server uses around 17K.

Tokens spent on tool definitions before the first message (approximate) GitHub (35 tools) ~26K tokens Slack (11 tools) ~21K tokens Sentry (5 tools) ~3K tokens Grafana (5 tools) ~3K tokens Splunk (2 tools) ~2K tokens Total: 58 tools, about 55K tokens of context gone before the user types anything. Anthropic reports 134K tokens of definitions internally before optimisation, and an 85% cut with deferred loading (Tool Search). Tool count is a weak predictor: Slack's 11 tools cost nearly as much as 35.
Approximate token cost of tool definitions in the five-server example from Anthropic's November 2025 advanced tool use post. Bar lengths are to scale.

Two things follow. First, the bill is driven by description and schema size, not tool count: Slack's 11 tools cost almost as much as GitHub's 35. Second, clients are now working around bloated servers, and their workarounds change how your text is read. Claude Code defers MCP tool definitions by default and loads them through tool search; its MCP docs say only tool names and server instructions load at session start, and that it cuts server instructions and upfront tool descriptions at 4,096 characters (16,384 for tools loaded through search). When tool search matches a query against a description, it reads only the part within the shorter limit. So the first sentence of every description has to say what the tool does, in the words a model would search for. That is the same discipline as writing a good commit subject line, which is why I think platform teams are well placed to own this.

How should you name MCP tools?

The spec is short on this. Tool names SHOULD be 1 to 128 characters, case-sensitive, unique within a server, and limited to ASCII letters, digits, underscore, hyphen and dot. No spaces or commas. The spec also warns that clients aggregating several servers will hit collisions (two servers each exposing search) and should prefix with a server identifier, and that serverInfo.name is not unique enough to rely on.

What works in practice:

  • Verb first, object second, snake_case: search_incidents, get_deployment, create_ticket. Models pattern-match on verbs, and snake_case survives every client's sanitiser. Clients layer their own rules on top (Claude Code, for one, accepts only letters, digits, hyphens and underscores in server names), so dots in your names may not survive every host.
  • Namespace by service or resource when your server will sit next to others: pagerduty_search_incidents rather than search. Anthropic's writing tools for agents guide (11 September 2025) found that prefix versus suffix namespacing measurably changed results and that the effect varied by model, so treat the scheme as something to test, not a style choice.
  • Use title for humans. The spec gives title (or annotations.title) display precedence over name, so the name can stay machine-shaped.

How many tools should an MCP server expose?

Fewer than you think. The most useful advice in Anthropic's guide is to stop mirroring APIs: replace list_users, list_events and create_event with one schedule_event that finds availability and books the slot, and replace read_logs with search_logs that returns relevant lines with context. OpenAI's function calling guide gives a soft ceiling of fewer than 20 functions available at the start of a turn. Both point the same way: each tool should map to a task a person would delegate, not to an endpoint.

The counter-pressure is that very coarse tools hide decisions the model should make, and push you towards a do_anything(action, payload) tool with a schema too loose to validate. My rule of thumb: one tool per user intent, with enums for the variations inside that intent. If two tools always get called in sequence with the output of one feeding the other, merge them. If one tool's description needs the word "or" to explain what it does, split it.

There is also a structural answer when you genuinely need hundreds of operations. Anthropic's code execution with MCP post (4 November 2025) describes presenting MCP servers as code APIs that an agent imports on demand, which cut one workflow from 150,000 tokens to 2,000. That needs a sandbox to run the code, which is the part most teams underestimate. (Disclosure: I build PandaStack, an open-source microVM platform for exactly this kind of agent code execution.)

How do you write MCP tool descriptions and input schemas a model follows?

Write the description for a new hire who has never seen your system, which is both Anthropic's framing and OpenAI's "intern test". Concretely:

  1. First sentence: what it does and when to use it. Front-load it because clients truncate and search on the start.
  2. Name parameters unambiguously. Anthropic's example is user_id instead of user. If an argument takes a format, say so with an example in the property description.
  3. Constrain with schema, not prose. An enum beats "must be prod or staging"; minimum and maximum beat "between 1 and 100". The spec allows any JSON Schema 2020-12 keyword in inputSchema, but keep composition (oneOf, if/then) to a minimum, because model support for complex schemas varies and the MCP Inspector's --strict mode exists precisely to flag schema portability problems.
  4. For tools with no arguments, the spec recommends {"type": "object", "additionalProperties": false}.
  5. State side effects and limits in the description: rate limits, whether it writes, how long created handles live.

Here is a definition that follows those rules, as it would appear in tools/list:

{
  "name": "pagerduty_search_incidents",
  "title": "Search PagerDuty incidents",
  "description": "Search incidents by service and status; use this before get_incident when you do not have an incident ID. Returns at most `limit` incidents, newest first, plus nextCursor if more exist. Read-only.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "service_name": { "type": "string", "description": "Exact service name, e.g. payments-api. Call list_services if unsure." },
      "status": { "type": "string", "enum": ["triggered", "acknowledged", "resolved"] },
      "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 10 },
      "cursor": { "type": "string", "description": "Opaque value from a previous nextCursor. Omit on the first call." },
      "response_format": { "type": "string", "enum": ["concise", "detailed"], "default": "concise" }
    },
    "required": ["service_name"],
    "additionalProperties": false
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "incidents": { "type": "array", "items": { "type": "object",
        "properties": { "id": { "type": "string" }, "title": { "type": "string" }, "status": { "type": "string" }, "opened_at": { "type": "string" } },
        "required": ["id", "title", "status"] } },
      "nextCursor": { "type": "string" }
    },
    "required": ["incidents"]
  },
  "annotations": { "readOnlyHint": true, "openWorldHint": true }
}

To check what your definitions actually cost, measure them rather than guessing. This is the check I run against a server with the official Inspector CLI (version pinned, Node 22.19 or later):

# Size of every tool definition as the client receives it (Inspector 2.13.0, jq 1.7.1)
npx -y @modelcontextprotocol/inspector@2.13.0 --cli node build/index.js \
  --method tools/list --format json 2>/dev/null \
| jq -r '"tools: \(.result.tools | length)",
         "tools/list bytes: \(tojson | length)",
         (.result.tools[] | "\(.name): description \(.description | length) chars, schema \(.inputSchema | tojson | length) bytes")'

# Fail CI on schema portability problems (exit code 6)
npx -y @modelcontextprotocol/inspector@2.13.0 --cli node build/index.js --method tools/list --strict > /dev/null

On a one-tool test server it printed tools/list bytes: 558. Any description approaching 4,096 characters is a sign the tool is doing too much. The companion guide on debugging an MCP server with MCP Inspector walks through the rest of the CLI.

When should MCP tools return structuredContent and an outputSchema?

Whenever the result is data rather than prose. Under the current spec, a tool returns unstructured content blocks (text, image, audio, resource links, embedded resources) and optionally a structuredContent JSON value. If you declare an outputSchema, servers MUST return structured results that conform to it and clients SHOULD validate them. For backwards compatibility, a tool returning structured content SHOULD also put the serialised JSON in a text block, because not every client passes structuredContent to the model.

The bigger design question is how much to return. Anthropic's guide has two findings worth copying. Resolving opaque UUIDs to human-readable names improved retrieval precision because the model hallucinated fewer identifiers. And a response_format enum let the agent choose: in their Slack example a detailed response was 206 tokens and a concise one 72. The client also enforces limits whether you plan for them or not. Claude Code warns when MCP tool output passes 10,000 tokens, caps it at 25,000 by default (MAX_MCP_OUTPUT_TOKENS), and saves text results over 50,000 characters to a file unless the tool declares _meta["anthropic/maxResultSizeChars"], per its MCP docs. For genuinely large payloads, return a resource_link and let the model read the resource if it needs it.

How should MCP tools report errors so the model can recover?

The spec defines two channels, and picking the right one is most of the work. Protocol errors (JSON-RPC errors such as -32602 for an unknown tool or a malformed request) are for things the model is unlikely to fix. Tool execution errors are a normal result with isError: true, and the spec explicitly lists API failures, input validation errors and business logic errors here, because clients SHOULD pass them to the model so it can self-correct.

So validation failures belong in isError, with text that says what to do next:

  • Bad: Error 400 or a Python traceback.
  • Good: No service named "payment". Known services: payments-api, payments-worker. Call list_services for the full list.
  • Good: start_date must be in the future; today is 2026-10-31.

The spec's non-normative guidance on stateful tools makes the same point for handles: MCP has no protocol session any more, so state lives in explicit handles that tools return, and a call with an expired handle should return a tool execution error saying so, so the model creates a new one. Put the handle's lifetime in the creating tool's description. If you want to see how errors land in a client, the official TypeScript SDK already turns schema validation failures into isError results; my test server returned Input validation error: Invalid arguments for tool ... for an empty string where minLength: 1 was declared.

Pagination, caching and versioning MCP tools

Practice What the 2026-07-28 spec says Source
Paginate tools/list Opaque cursor and nextCursor; server picks page size; invalid cursor SHOULD return -32602 Pagination
Paginate tool results Not in the protocol; add limit and cursor arguments yourself Anthropic guide
Cache hints tools/list MUST carry ttlMs and cacheScope (public or private) Caching
Stable order SHOULD return tools in deterministic order, which helps LLM prompt cache hits Tools
Change signal notifications/tools/list_changed over a subscriptions/listen stream Tools
Annotations readOnlyHint (default false), destructiveHint (default true), idempotentHint, openWorldHint are hints only Schema

Two of these are easy to get wrong. cacheScope: "public" means a shared gateway MAY serve your tool list to other users, so if the list varies by the caller's scopes (which the spec allows) it must be private. And the deterministic-order rule matters more than it looks: if your server builds the tool list from a dict or a set with unstable ordering, every reordering invalidates the client's prompt cache.

Versioning is the gap. There is no per-tool version field, and serverInfo.version describes the server, not the contract the model learned. Treat tool names as public API: add optional parameters freely, never change the meaning of an existing one, and ship a breaking change as a new name (the spec's own examples include DATA_EXPORT_v2), keeping the old tool until your logs show nobody calls it. Emit list_changed when the set changes. Annotations do not replace authorisation: the schema says clients should never make tool-use decisions from annotations of untrusted servers, so a readOnlyHint is a UX hint, not a control. My MCP security post covers what does count as a control.

FAQ

What is the maximum length of an MCP tool name?

The 2026-07-28 specification says tool names SHOULD be between 1 and 128 characters, case-sensitive, and use only ASCII letters, digits, underscore, hyphen and dot. Clients may sanitise further, so plain snake_case is the safest choice.

Should validation errors in an MCP tool be JSON-RPC errors or isError results?

Return them as a normal result with isError: true. The spec lists input validation errors as tool execution errors, which clients should show to the model so it can retry with corrected arguments. Reserve JSON-RPC errors for unknown tools and malformed requests.

How many tools should one MCP server have?

There is no protocol limit, but fewer, task-shaped tools work better than one tool per endpoint. OpenAI suggests fewer than 20 functions at the start of a turn, and Anthropic recommends consolidating related operations into single workflow tools.

Do I need an outputSchema for every MCP tool?

No. Use one when the tool returns data a client or model will parse, because the server must then conform to it and clients can validate it. Also return the same JSON as a text block, since some clients only pass text content to the model.

This quarter I would take the busiest MCP server a team runs, dump its tools/list with the Inspector CLI, and read every description as if I were the model: first sentence only, then the schema. I would merge tools that are always called together, move every validation failure into isError with a next step, add a limit and cursor to anything that returns lists, and put the --strict check in CI so the next change does not quietly undo the work. None of that is new engineering. It is API design and release discipline, applied to a consumer that reads your docs literally.

Related: How to Build an MCP Server in Python and TypeScript (2026), MCP Authorization Explained: OAuth 2.1, RFC 9728 and CIMD in 2026 and MCP in 2026: 475M SDK Downloads a Month, 39,492 Servers in a Registry Still in Preview, and 12 Gateways Selling the Same 3 Features.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related