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.
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.
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:
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:
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 |
|
Built from the function name and the route, so they do not read as a sentence ( |
Descriptions |
Say when to use the tool and what to call next |
The operation summary, for example |
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:
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_xandget_xinto 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
instructionsat 2,048 characters by default, so put the critical text first. -
Small schemas. Constrain with
Literalandpatterninstead of prose; drop parameters no one sets; skip anoutputSchemafor a tool whose result is plain text, asget_pagedoes. -
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_SEARCHenvironment variable switches it off, forces it on, or defers only once the definitions exceed a percentage of the window (auto,auto:5). The server-sideinstructionsthen 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.
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):
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();
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:
// 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 readsDOCS_MCP_URLand, 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 |
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? |
|
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.