Designing Good MCP Servers

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-09-29 against the 2026-07-28 revision of the specification, the Python mcp SDK 2.2.0 (Python 3.12) and fastmcp 4.0.10, @modelcontextprotocol/client 2.2.0 with TypeScript 7.0.2 on Node.js 24, and Spring AI 2.0.1 with the MCP Java SDK 2.0.0 that it pulls in on Spring Boot 4.1.1 (see the Model Context Protocol baseline). Every listing was run, or type-checked and run, against those packages; the measurements in the token-cost section come from those runs. The server is the docs-assistant of Python MCP Servers, redesigned here so that a model, not a programmer, is its caller.

An MCP tool is an interface with an unusual reader. Its description and schema are prose that a language model reads, on every request, before deciding what to call, and its result is text that the same model must digest within a limited context window. The habits that make a good REST API — one endpoint per operation, exhaustive fields, numeric IDs, terse error codes — make a poor tool set. This page turns that observation into concrete rules, shows them as code, measures what tool definitions cost, and ends with the pattern that removes most of that cost: letting the model call tools through code. The general theory of tool design for agents, independent of MCP, is on Tool Design; the CLI alternative to MCP servers is covered in Building CLIs for AI Agents, which also compares the two in CLI vs. MCP.

flowchart LR subgraph designed["Designed for an LLM"] D1[2 tools, readable IDs] --> D2[concise results by default] D2 --> D3[cursor when there is more] D3 --> D4[errors that say what to do next] end subgraph wrapped["Wrapped from a REST API"] W1[12+ tools, one per endpoint] --> W2[numeric IDs, full records] W2 --> W3[unbounded lists] W3 --> W4[HTTP status text as error] end

Tool design rules for LLM callers

Five rules cover most of the difference. The Python server below applies all of them to docs-assistant; the sections after it take the rules one at a time. Run it with python design_server.py; it serves Streamable HTTP on http://127.0.0.1:8000/mcp.

design_server.py — docs-assistant designed for a model (Python, mcp 2.2.0)
"""docs-assistant designed for an LLM caller: few tools, two detail levels, cursors, actionable errors, readable IDs."""
import base64
import json
from typing import Literal

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

import store

mcp = MCPServer(
    "docs-assistant", version="1.0.0",
    instructions="Project documentation. Call search_docs first, then get_page for the one or two pages you will quote.",
)
PAGE_SIZE = 2
Component = Literal["algebra", "units", "statistics"]


class Hit(BaseModel):
    id: str = Field(description="Readable page ID such as 'algebra/matrices'; pass it unchanged to get_page")
    title: str


class FullHit(Hit):
    snippet: str
    score: int


class SearchResult(BaseModel):
    hits: list[FullHit | Hit]
    next_cursor: str | None = Field(None, description="Pass as cursor to get the next page; null when done")


def _encode(offset: int) -> str:
    return base64.urlsafe_b64encode(json.dumps({"o": offset}).encode()).decode()


def _decode(cursor: str) -> int:
    try:
        return int(json.loads(base64.urlsafe_b64decode(cursor.encode()))["o"])
    except (ValueError, KeyError, TypeError):
        raise ToolError("Invalid cursor. Use the next_cursor value from the previous result, "
                        "or omit cursor to start from the first page.") from None


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def search_docs(query: str, component: Component | None = None,
                detail: Literal["concise", "full"] = "concise",
                cursor: str | None = None) -> SearchResult:
    """Find documentation pages. Start with detail='concise' (ID and title only, cheap);
    call get_page for the one or two pages you need to read. If next_cursor is not null,
    pass it as cursor to see more results instead of rewording the query."""
    if not query.strip():
        raise ToolError("query is empty. Pass a key phrase from the question, for example 'LU decomposition'.")
    hits = []
    for c in ([component] if component else store.COMPONENTS):
        hits += store.search(query, c, 50)
    hits.sort(key=lambda h: (-h["score"], h["component"], h["page"]))
    start = _decode(cursor) if cursor else 0
    window = hits[start:start + PAGE_SIZE]
    if not window and start == 0:
        raise ToolError(f"No pages match '{query}'. Try fewer or more general words, or drop the component filter.")
    items = [FullHit(id=f"{h['component']}/{h['page']}", title=h["title"], snippet=h["snippet"], score=h["score"])
             if detail == "full" else Hit(id=f"{h['component']}/{h['page']}", title=h["title"]) for h in window]
    more = start + PAGE_SIZE < len(hits)
    return SearchResult(hits=items, next_cursor=_encode(start + PAGE_SIZE) if more else None)


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def get_page(id: str) -> str:
    """Return the AsciiDoc source of one page. id comes from search_docs, e.g. 'algebra/matrices'."""
    component, _, page = id.partition("/")
    try:
        return store.read(component, page)
    except KeyError:
        raise ToolError(f"Unknown page '{id}'. IDs look like 'algebra/matrices'; call search_docs to find one.") from None


if __name__ == "__main__":
    mcp.run("streamable-http")

The behaviour below was observed through the MCP Inspector CLI (see Testing & Debugging) against this server, on a four-page stub store with a page size of two.

Few, well-described tools

Give the model the smallest set of tools that lets it finish the task, and make each one a task, not an endpoint. The server has two tools, search_docs and get_page, because a documentation assistant does two things: find pages, and read one. A user’s question never needs a list_components, get_component or list_revisions step in the middle.

The description is the model’s only documentation, so it says when to use the tool and what to do next, not just what it is. search_docs tells the model to start concise, to call get_page for the pages it needs and to follow next_cursor instead of rewording the query. The Field(description=…​) strings on the output model do the same job for each field: the description of id says the value is passed unchanged to get_page. Constrain what can be constrained in the schema — component is a Literal, so the model sees the three valid values and cannot invent a fourth. At server level, the instructions argument of MCPServer carries guidance that applies across tools; it is returned to clients in the server/discover result, and the run above returned exactly the sentence in the listing.

The tool annotations readOnlyHint and openWorldHint describe the tools to a host, which may use them to skip a confirmation prompt for a trusted server. They are hints from the server and must never stand in for authorization; see Annotations are hints, not guarantees and MCP Security.

Concise and detailed modes

Most calls need a title and an ID, not a paragraph. The detail argument defaults to concise, which returns only id and title; full adds the snippet and score. The two shapes are two Pydantic models (Hit and its subclass FullHit), so a concise result carries no null placeholders that would cost tokens for nothing. For the same two hits, the JSON text of the concise result was 193 characters and the detailed one 392 characters; on real snippets of a few hundred characters each, the gap is far wider. Call get_page, or a follow-up tool, when the model needs the whole text, so that it pays for the full text of one or two pages rather than for every hit.

Pagination

Never return an unbounded list. search_docs returns at most PAGE_SIZE hits and a next_cursor; passing the cursor back returns the next window, and next_cursor is null on the last page, which is the model’s signal to stop. The cursor is opaque (a base64-encoded offset here) so the model does not try to compute one. This is the application-level counterpart of the protocol’s own cursor on tools/list and resources/list: the protocol pages the catalogue, and your tool pages its results. A tool that can return thousands of rows should also cap the response server-side, because a host may truncate it for you (Claude Code warns above 10,000 tokens and enforces a default limit of 25,000 tokens per MCP tool result, according to its documentation; see Connecting Hosts).

Actionable errors

The specification distinguishes two kinds of failure. A protocol error (unknown tool, malformed request) is a JSON-RPC error that models are unlikely to repair. A tool execution error — an API failure, a validation failure, a business rule — is reported in a normal result with isError: true, and the specification asks clients to pass those to the model so that it can correct itself. Design for the second kind: say what was wrong and what to try. In the Python SDK, raising ToolError from mcp.server.mcpserver.exceptions produces exactly that result. These are the three failures the server produced, verbatim, each with isError: true:

Error executing tool search_docs: query is empty. Pass a key phrase from the question, for example 'LU decomposition'.
Error executing tool search_docs: No pages match 'zzz'. Try fewer or more general words, or drop the component filter.
Error executing tool get_page: Unknown page 'algebra/nope'. IDs look like 'algebra/matrices'; call search_docs to find one.

A bare Not found or a stack trace gives the model nothing to act on. An error that includes the valid shape (IDs look like 'algebra/matrices') and the next tool to call lets it recover in one step. An error must also leak nothing an attacker would want; the security page returns the same message for a missing and a forbidden page for that reason. The wire format of both kinds of error is on Primitives.

Human-readable IDs

Models follow names, not numbers. A hit’s id is algebra/matrices, which is also the tail of the resource URI docs://algebra/matrices (see the template on Primitives); the model can read it, quote it in an answer and pass it to get_page without translation. An opaque numeric key such as 4711 invites a fabricated 4712. Where an internal key must exist, return the readable identifier and resolve it inside the server.

The same rules in Java

Spring AI’s @McpTool carries the same rules. @McpToolParam(description = …​) documents each argument, and a thrown IllegalArgumentException becomes an isError result whose text is the exception message. One Spring-specific detail cost a run to find: with generateOutputSchema = true the generated outputSchema marks every record component as required, so a result with an omitted field is rejected by the server’s own output validation. Fields that are sometimes absent need both @JsonInclude(NON_NULL) on the record and @JsonProperty(required = false) on the component, as in the listing:

DesignTools.java — concise or full detail, a cursor and actionable errors (Spring AI 2.0.1)
package dev.example.docsassistant;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.stereotype.Component;

@Component
public class DesignTools {

    private static final Map<String, String> PAGES = Map.of(
            "algebra/matrices", "Matrix decompositions such as LU, QR and SVD solve linear systems.",
            "units/conversion", "Convert lengths, masses and times between unit systems.");

    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record PageRef(String id, String title, @JsonProperty(required = false) String text) {}

    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record Listing(List<PageRef> pages, @JsonProperty(required = false) String nextCursor) {}

    @McpTool(name = "list_pages",
            description = "List documentation pages. Start with detail=concise (IDs only, cheap); use get_page for the "
                    + "one or two you need. If nextCursor is not null, pass it as cursor to see more.",
            generateOutputSchema = true,
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false))
    public Listing listPages(
            @McpToolParam(description = "concise or full", required = false) String detail,
            @McpToolParam(description = "nextCursor of the previous call", required = false) String cursor) {
        if (detail != null && !detail.equals("concise") && !detail.equals("full")) {
            throw new IllegalArgumentException("detail must be 'concise' or 'full', not '" + detail
                    + "'. Omit it to get concise results.");
        }
        int start = cursor == null ? 0 : parse(cursor);
        var ids = PAGES.keySet().stream().sorted().toList();
        var window = ids.subList(Math.min(start, ids.size()), Math.min(start + 1, ids.size()));
        var pages = window.stream().map(id -> new PageRef(id, id.substring(id.indexOf('/') + 1),
                "full".equals(detail) ? PAGES.get(id) : null)).toList();
        return new Listing(pages, start + 1 < ids.size() ? Integer.toString(start + 1) : null);
    }

    private static int parse(String cursor) {
        try {
            return Integer.parseInt(cursor);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Invalid cursor. Use nextCursor from the previous result, "
                    + "or omit cursor to start from the first page.");
        }
    }
}

The tool ran on Spring Boot 4.1.1 behind the JWT resource-server configuration of Authorization; a request with detail=loud returned isError: true with the message from the listing. The JVM’s default locale can leak into framework validation messages (one run returned a Spanish sentence for a schema error), so set -Duser.language=en if the model is to read them. The Java SDK speaks protocol 2025-11-25, so this server has no server/discover and no multi-round-trip requests (see Spring Boot MCP Servers). The annotations are described on the Spring AI MCP annotations reference page.

Curated tools versus generated wrappers

A REST API that already has an OpenAPI description is tempting to expose as-is: FastMCP’s FastMCP.from_fastapi() and the fastapi-mcp package do it in a line (see MCP inside FastAPI). The result is a useful prototype and a poor product, for reasons that show up in a measurement. This is the generated server for a typical twelve-operation CRUD API, listed by the Inspector:

Tool names generated by FastMCP.from_fastapi() for a 12-operation REST API (fastmcp 4.0.10)
list_components_components_get     create_component_components_post   get_component_components
replace_component_components       delete_component_components        list_pages_components
create_page_components             get_page_pages                     delete_page_pages
update_page_pages                  search_search_get                  list_revisions_pages
Property Curated (design_server.py) Generated from the REST API

Tool count

2, one per task

12, one per operation

Names

search_docs, get_page

Built from the function name and the route, so they do not read as a sentence (list_pages_components for a route that ends in /pages)

Descriptions

Say when to use the tool and what to call next

The operation summary, for example List Components

Mutating tools

None

Six of twelve (create, replace, delete and update, on components and pages), unless an allow-list removes them

Results

Concise by default, cursor, readable IDs

Whatever the endpoint returns, including numeric IDs and full records

Estimated definition cost

about 500 tokens

about 1,050 tokens

Two conclusions follow. First, generated tools are not the token problem at this size — twelve terse tools cost about twice two carefully described ones — but they are an information problem: nothing tells the model which of six similarly named …​_components tools to call, and nothing tells it that delete_page_pages is dangerous. Second, they inherit the API’s authority. Everything the REST API can do, the model can now do, and six of those twelve tools change data. If you generate, generate an allow-listed, read-only subset (RouteMap(methods=[…​], mcp_type=MCPType.EXCLUDE) for from_fastapi(), and include_operations=[…​] for FastApiMCP, exist for that), then rename, re-describe and wrap. The rule of thumb is the one on Generating tools from OpenAPI: generation is a starting point, not an end state. A curated tool can also compose several REST calls — search, then fetch the first hit’s summary — in one step that is a single round trip for the model, something no one-endpoint-one-tool wrapper can do.

Token cost of tool definitions

Every tool the model can call is described to it on every request: its name, its description and its input schema (and output schema, when the host forwards it). That text occupies the context window before the user has typed a word. For a server with tools, where is the number of tokens in tool 's name and description and is the number of tokens in its schemas, the standing cost is

and it is paid again on each model call in the conversation (a provider’s prompt caching lowers the price of the repeated prefix but not its place in the window). T is the quantity to keep small, and it is easy to measure from the server itself. This script reads the JSON of a tools/list result, as printed by mcp-inspector --cli …​ --method tools/list, and applies the rough rule of four characters per token; count with your provider’s tokenizer when precision matters:

tool_budget.py — estimate T from a tools/list result
"""Estimate the context cost of a server's tool definitions: T = sum(d_i + s_i), at ~4 characters per token."""
import json
import sys

CHARS_PER_TOKEN = 4  # rule of thumb; count with your model provider's tokenizer for a real budget


def budget(tools: list[dict]) -> tuple[int, list[tuple[str, int, int]]]:
    rows = []
    for t in tools:
        d = len(t["name"]) + len(t.get("description") or "")
        s = len(json.dumps(t.get("inputSchema", {}), separators=(",", ":")))
        s += len(json.dumps(t.get("outputSchema", {}), separators=(",", ":")))
        rows.append((t["name"], d // CHARS_PER_TOKEN, s // CHARS_PER_TOKEN))
    return sum(d + s for _, d, s in rows), rows


if __name__ == "__main__":
    tools = json.load(open(sys.argv[1]))["result"]["tools"]
    total, rows = budget(tools)
    for name, d, s in rows:
        print(f"{name:40} d={d:4} s={s:4}")
    print(f"{len(rows)} tools, T = {total} tokens")

Run on the two servers of the previous section:

search_docs                              d=  63 s= 351
get_page                                 d=  24 s=  59
2 tools, T = 497 tokens

list_components_components_get           d=  11 s=  93
create_component_components_post         d=  12 s=  67
...
12 tools, T = 1047 tokens

The curated search_docs has the largest single schema of either server, 351 tokens, because its outputSchema describes the Hit and FullHit shapes and their field descriptions; that is the price of a structured, self-describing result, and it is usually worth paying. The generated server averages about 87 tokens per tool, so at that rate a catalogue of 100 tools costs roughly 8,700 tokens on every request, and a host connected to several large servers can spend a significant share of its window on definitions alone.

What lowers T:

  • Fewer, better tools — the first rule above. Merging list_x and get_x into one search tool is a saving and a clarity gain.

  • Short descriptions that front-load the decision. Hosts truncate: Claude Code cuts each tool description and each server’s instructions at 2,048 characters by default, so put the critical text first.

  • Small schemas. Constrain with Literal and pattern instead of prose; drop parameters no one sets; skip an outputSchema for a tool whose result is plain text, as get_page does.

  • Let the host defer them. With tool search, hosts load only tool names up front and fetch definitions when needed. Claude Code enables this by default, and its ENABLE_TOOL_SEARCH environment variable switches it off, forces it on, or defers only once the definitions exceed a percentage of the window (auto, auto:5). The server-side instructions then matter more, because they are what tells the model when to search for your tools. See Scale with MCP tool search.

  • Let the model call tools through code — the next section.

Code execution with MCP

Direct tool calling has two costs: the definitions above, and the fact that every intermediate result travels through the model. Fetching a long list, filtering it and passing part of it to another tool means the model reads, and re-types, all of it. Anthropic’s engineering article on the subject describes the alternative: present MCP servers to the agent as code APIs, so the agent writes a small program that calls the tools, filters and joins the data in an execution environment, and returns only a summary. It reports a worked example that fell from about 150,000 tokens to about 2,000; treat the figure as illustrating the mechanism rather than as a benchmark of your workload. Cloudflare’s "Code Mode" is a related implementation of the same idea.

flowchart TB subgraph direct["Direct tool calls"] M1[Model] -->|1. call search_docs| S1[MCP server] S1 -->|full result, every hit| M1 M1 -->|2. call get_page| S1 S1 -->|full page| M1 end subgraph code["Code execution with MCP"] M2[Model] -->|writes a script| R[Sandboxed runtime] R -->|tools/call, many| S2[MCP server] S2 -->|results stay in the runtime| R R -->|only the printed summary| M2 end

The mechanics are small. A generic helper, callMcpTool, is the only code that speaks MCP; a generated wrapper per tool gives the model a typed function to read and call; and the model’s own script is ordinary code. This is the helper and a wrapper for search_docs, in TypeScript with the real @modelcontextprotocol/client package (it type-checks with tsc --noEmit on TypeScript 7.0.2 and ran against the server above):

client.ts — the one place that talks MCP
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const client = new Client({ name: 'code-mode-runtime', version: '1.0.0' });
let connected: Promise<void> | undefined;

/** The one place that talks MCP: connection, auth headers and logging live here, not in generated files. */
export async function callMcpTool<T>(tool: string, args: Record<string, unknown>): Promise<T> {
  // Share one connection, but forget a failed attempt so the next call reconnects instead of rethrowing it forever.
  connected ??= client.connect(new StreamableHTTPClientTransport(new URL(process.env.DOCS_MCP_URL!)))
    .catch((err: unknown) => { connected = undefined; throw err; });
  await connected;
  const result = await client.callTool({ name: tool, arguments: args });
  if (result.isError) throw new Error(JSON.stringify(result.content));
  return result.structuredContent as T;
}

export const closeMcp = () => client.close();
servers/docs/searchDocs.ts — a typed wrapper, one file per tool
import { callMcpTool } from '../../client.js';

export interface SearchDocsInput {
  query: string;
  component?: 'algebra' | 'units' | 'statistics';
  /** 'concise' returns IDs and titles only; 'full' adds snippets and scores. */
  detail?: 'concise' | 'full';
  cursor?: string;
}

export interface Hit { id: string; title: string; snippet?: string; score?: number }
export interface SearchDocsOutput { hits: Hit[]; next_cursor: string | null }

/** Find documentation pages. Follow next_cursor for more results. */
export function searchDocs(input: SearchDocsInput): Promise<SearchDocsOutput> {
  return callMcpTool<SearchDocsOutput>('search_docs', { ...input });
}

With a servers/docs/index.ts that re-exports it, the model can explore the directory, read only the files for the tools it needs (progressive disclosure, instead of loading every definition), and write:

task.ts — what the model writes: page through, keep only what matters
// What the model writes: page through results in code, keep only what the answer needs.
import * as docs from './servers/docs/index.js';
import { closeMcp } from './client.js';

const ids: string[] = [];
let cursor: string | undefined;
do {
  const page = await docs.searchDocs({ query: 'matrix solve convert distributions', cursor });
  ids.push(...page.hits.map(h => h.id));
  cursor = page.next_cursor ?? undefined;
} while (cursor);

console.log(`${ids.length} matching pages; first two: ${ids.slice(0, 2).join(', ')}`);   // only this reaches the model
await closeMcp();

Run against design_server.py, it printed one line, 4 matching pages; first two: algebra/matrices, algebra/wrappers. Only that line reaches the model’s context; the cursor loop, and every intermediate result, stayed in the runtime. The article also describes a search_tools tool with a detail-level parameter as an alternative to a filesystem of wrappers, which applies the concise-versus-detailed rule to the tool catalogue itself.

The trade-off is that the model now writes and runs code, so the runtime becomes part of your security boundary:

  • Sandbox the runtime. The script is model-generated and may be influenced by anything the model read. Run it with no ambient credentials, a restricted filesystem, resource limits and an egress allow-list (see MCP Security).

  • Credentials live in callMcpTool, not in generated code. The helper reads DOCS_MCP_URL and, for a protected server, the token from the environment or a secret store; nothing the model writes ever contains one.

  • Keep the approval gate. A wrapper for a destructive tool must still ask the user (see the approval-gate example on the security page); code execution changes who calls the tool, not who is accountable for it.

  • Log at the helper. One choke point means one place to record every tools/call, with arguments and outcome.

The safety controls for the agent around the runtime are on Agent Guardrails & Safety and Human-in-the-Loop.

Checklist

Question Rule

How many tools?

As few as the task needs; one per task, not per endpoint. Measure T with tool_budget.py.

What does a description say?

When to use the tool, what the arguments mean, what to call next. Critical text first.

What does a result contain?

Concise by default, detail on request, a cursor when there is more, readable IDs.

What does a failure return?

isError: true with what was wrong and what to try; never a stack trace or a bare status.

Generated from OpenAPI?

Only an allow-listed, read-only subset, renamed and re-described.

Many servers or tools?

Rely on host tool search, or route through code execution in a sandbox.

Destructive tool?

Its own scope, an approval gate and a reversible action; annotations are only hints.

Retrieval-specific design (search and read tools with an outputSchema, handles and ACL filters) continues on Exposing RAG through MCP.