Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 13: Performance & Token Economics

Most MCP performance discussions start in the wrong place. Teams measure handler latency, tune a database query, shave a few milliseconds off an HTTP call, and then wonder why the overall experience still feels slow and expensive.

An MCP server is judged less by how fast it executes code than by how efficiently a model can discover it, understand it, choose the right capability, call it with the right arguments, recover from mistakes, and move on. A tool that executes in 120 milliseconds can still produce a bad user experience if the model needed two extra rounds to find it, or if the client had to ship a bloated tool catalog into context before the task even started.

That is why performance and token economics belong in the same chapter. In MCP work, latency and token cost are coupled. Bigger capability surfaces increase model reasoning burden. Verbose schemas increase prompt overhead. Overly chatty tool results force the model to read and summarize data that the server could have filtered in the first place. Remote transports add network behavior on top of normal execution time. If you optimize one layer while ignoring the others, you usually just move the bottleneck.

Performance work in MCP is mostly interface discipline.

Start With a Whole-Path Budget

Before you tune anything, define what “fast enough” means for the actual workflow.

For a typical MCP interaction, end-to-end latency includes at least six moving parts:

  1. the client’s capability discovery,
  2. the model’s tool-selection and argument-planning time,
  3. transport overhead between client and MCP server,
  4. MCP server validation and execution,
  5. downstream dependency latency,
  6. and any retry, approval, or recovery loop after an error.

Only two of those are backend time you control; the rest belong to the model, the client and the network.

That is why operator teams should set a whole-path budget instead of a handler-only budget. For example, a read-oriented tool used in a live chat flow might need a first useful result in under two seconds. A background analysis tool may tolerate ten or twenty seconds if progress reporting is clear. A write tool that requires explicit approval might be acceptable at five seconds if the result is definitive and idempotent. The number is less important than the discipline: define the target for the workflow, not just the function.

A simple budget worksheet looks like this (illustrative ranges; replace them with your own trace):

  • tool discovery and model deliberation: 400–900 ms
  • transport and network setup: 100–300 ms on a healthy remote path
  • MCP server validation and orchestration: 50–150 ms
  • downstream API or database call: 200–900 ms
  • response serialization and client rendering: 50–150 ms

If that workflow regularly blows past the budget, ask where the extra time actually goes. Often the slowest part is not the business logic but the cost of making the model think through an unnecessarily large surface area.

Tool Definitions Are Part of the Bill

Every tool name, description, enum, field description, and schema branch is a tax on context.

The tax is paid before the first useful call. If your MCP server publishes fifty tools with long descriptions and deeply nested schemas, you are asking the model to spend tokens and reasoning effort sorting through your architecture.

A practical rule is to treat each tool definition as production prompt text. If you would not paste it into a system prompt without review, do not publish it casually through MCP.

Suppose you have a server with 30 tools, each averaging:

  • 10 tokens for the name and title,
  • 60 tokens for the description,
  • 180 tokens for input schema keys, types, enums, and descriptions,
  • 50 tokens for output schema and annotations.

That is roughly 300 tokens per tool, or about 9,000 tokens before the model has done any task work. At 100 tools, the same design reaches roughly 30,000 tokens. On a model billed per million input tokens, even a modest per-request price turns that catalog into a recurring tax. Using a simple pricing example, if your input price were $5 per 1M tokens, a 30,000-token catalog costs about $0.15 every time the full surface is shipped into context. Multiply that by retries, multi-turn sessions, or parallel users, and the “just add another tool” habit gets expensive fast.

Prompt caching changes that arithmetic, and it makes the argument sharper. Most hosts place tool definitions at the front of the prompt, and providers that cache prompt prefixes bill a cache hit at a fraction of the normal input price. A catalog that is byte-for-byte identical from turn to turn is paid for once and then read cheaply for the rest of the session. A catalog that changes defeats the cache: tools listed in a different order, a description with a timestamp or request ID in it, or a per-turn filter that adds and drops tools. Each of those turns the full catalog back into full-price input on every turn. Stability is a cost feature. Sort tools deterministically, keep descriptions free of volatile values, and change the surface in releases, not per request.

Good tool-definition optimization usually means:

  • merging overlapping tools that differ only by trivial flags,
  • splitting unrelated domains into separate MCP servers,
  • removing decorative language from descriptions,
  • replacing vague free-form fields with focused enums where appropriate,
  • and refusing to expose internal implementation detail that the model does not need.

A bad description says, “This incredibly flexible endpoint can be used for lots of repository, workflow, and environment management cases.” A better description says, “Create a deployment for a selected environment and return the deployment ID and status URL.” The second version is shorter and easier for a model to choose correctly.

Design Schemas for Selection, Not Just Validation

Engineers often think of schemas as input validation. In MCP, schemas also shape tool selection.

That matters because the model reads the schema before it ever sends the arguments. If the schema is overloaded, the model pays the cost up front. A tool with fifteen optional fields, cross-cutting semantics, and soft constraints may be technically valid JSON Schema, but it forces the model to infer too much.

Here is a typical anti-pattern:

{
  "type": "object",
  "properties": {
    "query": { "type": "string" },
    "resourceType": { "type": "string" },
    "scope": { "type": "string" },
    "limit": { "type": "integer" },
    "includeArchived": { "type": "boolean" },
    "sort": { "type": "string" },
    "filters": { "type": "object" },
    "mode": { "type": "string" }
  }
}

This kind of “do-everything search” tool is attractive to backend engineers because it centralizes logic. It is often bad for MCP performance because the model must reason through ambiguity every time.

Split it when the fields partition by intent, that is, when each caller only ever uses a subset of them. Here that yields two or three narrower tools with clearer contracts:

  • search_documents
  • search_tickets
  • list_recent_deployments

The narrower surface can reduce wrong calls, reduce retries, reduce schema size, and improve completion time even if the underlying backend code is identical.

This is one of the central performance truths of MCP: fewer decisions for the model often beat faster code on the server.

Response Size Is a Hidden Latency Multiplier

The second major tax is output size.

Teams usually notice large outputs when billing spikes, but the more immediate damage is behavioral. Large tool responses take longer to serialize, transmit and read before the model can decide the next step, and they bury the useful fact inside irrelevant detail.

In practice, good response discipline means returning three things:

  1. the fields needed for the immediate next decision,
  2. stable identifiers for follow-up retrieval,
  3. and a compact summary that preserves meaning.

For example, instead of returning 500 full ticket objects from search_tickets, return:

{
  "total": 500,
  "returned": 10,
  "tickets": [
    {
      "id": "T-1042",
      "title": "Production deploy failed in us-east-1",
      "status": "open",
      "severity": "high"
    }
  ],
  "next_cursor": "c_eyJvZm...EwfQ"
}

If the model needs the complete body of ticket T-1042, give it a separate detail tool or a resource link. The same goes for any larger artifact a result points to, such as a log file, a generated report or a repository path: return the compact result plus a link the client can fetch when needed. That is faster and cheaper than sending 500 records “just in case.”

Remote Performance Is Mostly Transport Hygiene

Stdio and Streamable HTTP fail differently, and Chapter 7 covers both transports and the proxy configuration that protects them. The performance work on a remote path comes down to a short list:

  • Reuse clients and connections instead of reconnecting for every call. On 2026-07-28 servers there is no handshake to repeat, but clients that call server/discover should cache the result.
  • Honour the ttlMs and cacheScope hints that 2026-07-28 servers return on list and read results, instead of re-listing tools on every turn.
  • Understand that a dropped stream is not the same thing as a cancelled request.
  • Decide deliberately whether long-running work should stream progress, return quickly with a handle, or move to an async job pattern.

Kill Cold Starts Before They Kill the Experience

Cold start pain is brutal in MCP because users and models both perceive it as uncertainty.

When the first request to a remote MCP server must boot a container, initialize SDK state, open database pools, fetch secrets, and warm an embedding index, the user does not experience “normal infrastructure behavior.” They experience a tool that feels unreliable.

You have a few standard levers:

  • keep worker or container instances warm where economics allow it,
  • lazily initialize expensive dependencies that not every tool needs,
  • prebuild indexes or metadata at deploy time rather than first request,
  • reuse HTTP clients and database pools across requests, created once per process rather than per tool call,
  • and move heavyweight startup checks out of the request path.

Cache the Right Things, Not Everything

Caching is useful in MCP, but sloppy caching creates a different kind of failure: confidently wrong answers.

The best cache targets are read-heavy, moderately stable, and expensive to compute: service metadata, capability catalogs assembled from downstream systems, project summaries, directory listings and retrieval indexes. Keep small reference data in process, move to a shared cache once you run more than one instance, and precompute anything expensive enough to build at deploy time.

The trick is to pair cache speed with freshness semantics. If your get_project_status tool may be cached for 30 seconds, put that in the result, where the model can read it:

{
  "project": "billing-api",
  "status": "degraded",
  "as_of": "2026-09-28T14:02:10Z",
  "stale_after": "2026-09-28T14:02:40Z"
}

A model that is about to act on the status can compare stale_after with the current time and call again instead of acting on old state. If your search index updates every ten minutes, design the surrounding workflow so that stale results cannot accidentally trigger destructive writes.

A pragmatic pattern is to cache discovery data aggressively and action data conservatively. Tool schemas, account metadata, or repository lists are often safe to cache briefly. A balance sheet, deployment approval state, or current incident status may require much tighter freshness or explicit revalidation.

Cache invalidation is also where MCP design matters. If one giant tool mixes stable metadata with volatile state, you are forced into the worst of both worlds: either stale critical data or constant cache misses. Narrower tools often let you cache the stable parts and fetch the volatile parts live.

Progressive Discovery Beats Monolith Servers

“Too many tools” is a token problem, a reliability problem and a performance problem, not only a UI problem. Use concrete thresholds. Past about 20 tools in a session, measure tool-selection accuracy with a scenario eval (Chapter 12) instead of assuming it holds. Past about 40, use tool search or split the server. Some clients also impose practical limits of their own, whether through explicit caps, degraded UI, or model-context constraints. Even when a client technically supports a huge tool list, that does not mean it is wise to use one.

Progressive discovery can take several forms:

  • split a large internal platform into multiple MCP servers by domain,
  • expose a compact default tool set and a larger admin server separately,
  • use resources or prompts to guide the model toward follow-up tools rather than front-loading everything,
  • or dynamically advertise only the tools that are valid for the current account, project, or permission scope.

Be careful, though: dynamic discovery is not free. If the tool list changes per session or over time, clients may need to handle notifications/tools/list_changed, your own testing surface gets more complex, and every change invalidates the prompt cache. Use progressive discovery to reduce cognitive and token load, not to create a shape-shifting interface that is impossible to reason about.

Benchmark Workflows, Not Just Functions

Microbenchmarks still matter. If a parser is quadratic, an ORM query is wasteful, or a database’s p95 latency drifts, fix it; benchmark schema generation, serialization of large results and your dependency calls the way you would for any service. But in MCP, scenario benchmarks are the ones that tell the truth.

Run whole workflows with a real client harness and inspect:

  • time to first useful result,
  • total tool calls per successful task,
  • average tokens consumed by tool definitions,
  • average tokens consumed by tool results,
  • retry and recovery count,
  • user approval round-trips,
  • and success rate under realistic prompts.

These are where you discover that a “fast” tool is part of a slow workflow because the model keeps choosing it first and then backtracking. A useful benchmark prompt might be: “Find the last failed deployment in production, summarize the root cause from the incident notes, and list the follow-up ticket IDs.” Then measure how many turns, tool calls, tokens, and seconds the system actually uses. That is far more valuable than knowing the raw latency of get_incident_by_id in isolation.

Performance Regressions Need CI Guardrails

Once you have a baseline, fail release checks on meaningful drift in tool count, definition size, response payload size, p95 tool latency and time to first useful result in one or two critical scenarios. This is the boring discipline that keeps an MCP server from getting slower feature by feature, teammate by teammate.

The cheapest of these checks is the size of the published surface. It needs no model and no network, because it reads tools/list through the same in-process client as Chapter 12’s contract test:

# ci/surface_budget.py: fail the build when the published surface outgrows its budget.
import asyncio
import json
import sys
from pathlib import Path

from mcp import Client

from tickets.server import build_server


async def main() -> int:
    budget = json.loads(Path("surface_budget.json").read_text())
    async with Client(build_server(api=None, whoami=lambda: "ci")) as client:
        tools = (await client.list_tools()).tools
    sizes = {t.name: len(json.dumps(t.model_dump(by_alias=True, exclude_none=True))) for t in tools}
    total = sum(sizes.values())
    print(f"{len(tools)} tools, {total} bytes, ~{total // 4} tokens")
    for name, size in sorted(sizes.items(), key=lambda kv: -kv[1]):
        print(f"  {size:>6}  {name}")
    over = [f"{label}: {actual} > {budget[key]}" for label, key, actual in (
        ("tools", "max_tools", len(tools)), ("bytes", "max_definition_bytes", total))
        if actual > budget[key]]
    for problem in over:
        print(f"OVER BUDGET {problem}", file=sys.stderr)
    return 1 if over else 0


sys.exit(asyncio.run(main()))

The budget lives next to it in surface_budget.json, for example {"max_tools": 20, "max_definition_bytes": 12000}, and it is reviewed like code. On the tickets server the script prints 1 tools, 313 bytes, ~78 tokens and exits 0. Four bytes per token is a rough estimate, which is fine for a trend line; swap in your provider’s tokenizer if you price against it. The per-tool list puts the biggest definitions at the top of the build log, so a reviewer sees which tool grew. Raising the budget becomes a visible diff with a reason in the commit message instead of a drift nobody noticed.

Failure Modes to Expect

Performance bugs in MCP rarely announce themselves as “performance bugs.” They usually show up as confusion, retries, or rising cost.

FailureFirst symptomFix in
The tool catalog is too large (past ~20 tools unmeasured, past ~40 unsplit)Rising input-token cost, more wrong tool choicesTool Definitions Are Part of the Bill; Progressive Discovery
One tool does too muchBloated schema, ambiguous arguments, lower successful-call rateDesign Schemas for Selection
Results are verbose but unhelpfulHigher token bills, summaries that miss the key factResponse Size Is a Hidden Latency Multiplier
Remote connections are not reusedRepeated discovery, re-listing tools every turn, extra network setupRemote Performance Is Mostly Transport Hygiene
Cache strategy hides freshness problemsFast but wrong answers, duplicate writes from stale stateCache the Right Things
Cold starts masquerade as random timeoutsFirst request fails, second works, retries multiply loadKill Cold Starts

Implementation Checklist

Before you call an MCP server “fast enough,” verify the following:

  • Define latency and cost budgets for the workflow, not just the handler.
  • Keep the tool catalog byte-stable between releases so prompt caching holds.
  • Return summaries, identifiers, and cursors instead of full objects by default.
  • Reuse HTTP connections, database pools, and SDK client objects.
  • Validate reverse-proxy behavior for Streamable HTTP, SSE response streams, and MCP headers (Mcp-Method, Mcp-Name, and Mcp-Session-Id for older clients).
  • Put as_of and stale_after on any cached result the model might act on.
  • Benchmark end-to-end scenarios with realistic prompts and recovery paths.
  • Run the surface-budget script in CI, plus checks for payload growth and p95 latency drift.

The easiest way to improve MCP performance is usually a smaller, clearer interface, not a faster algorithm.

What Comes Next

Everything so far has assumed a generic client. Part V drops that assumption: the same MCP server behaves differently in Claude, ChatGPT and IDEs, and those differences decide what “fast enough” and “correct” mean in practice.