TL;DR: Every tool your MCP server exposes has a name, a description and a JSON Schema, and most clients send all of them to the model on every request, before the user's question is even read. Stack a few servers and the tool list alone can eat a large slice of the context window. Below: a script that measures your server's real token cost, the four things that usually bloat it, and how to design tools that stay small without getting less useful.
Here's a slightly uncomfortable take: most MCP servers are designed like REST APIs, and that's exactly why they're expensive. One tool per endpoint, a paragraph of docs per parameter, every enum value spelled out. It reads well in a code review. To a model, it's thousands of tokens of preamble on every single turn.
Where the tokens actually go
When a client connects to an MCP server, it calls tools/list. Each tool comes back with:
namedescription-
inputSchema(a JSON Schema object) - optionally
outputSchema,titleandannotations
The client then turns those definitions into whatever its model API expects and includes them in the request, so the model knows what it can call. That's the whole mechanism: the tool list is just more prompt. The tools section of the MCP specification is short and worth reading once, because it makes clear that nothing in the protocol makes definitions free.
Three things multiply the cost:
- Every enabled server counts. A developer with a GitHub server, a database server, a browser server and an internal one is paying for all four lists on every turn, even when the task only needs one.
- It repeats. Prompt caching reduces the price of repeated prefixes on some providers, but the definitions still occupy context window space, and a changed tool list invalidates the cache.
-
Big tool lists hurt accuracy, not just cost. With dozens of similar tools (
get_user,get_user_by_email,search_users,list_users), the model has more chances to pick the wrong one.
Measure it before you trim it
Guessing is pointless; measure. This script starts your server over stdio with the official Python SDK, lists its tools, and asks Anthropic's token counting endpoint how many input tokens the definitions add. The trick is to count the same tiny message with and without tools and take the difference.
# measure_tools.py
import asyncio
import os
import sys
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
MODEL = os.environ["ANTHROPIC_MODEL"] # the model you actually run in production
async def list_tools(command: str, args: list[str]):
params = StdioServerParameters(command=command, args=args)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.list_tools()
return result.tools
def count(client, tools=None) -> int:
kwargs = {"tools": tools} if tools else {}
resp = client.messages.count_tokens(
model=MODEL,
messages=[{"role": "user", "content": "hi"}],
**kwargs,
)
return resp.input_tokens
def main():
command, *args = sys.argv[1:]
tools = asyncio.run(list_tools(command, args))
defs = [
{"name": t.name, "description": t.description or "", "input_schema": t.inputSchema}
for t in tools
]
client = anthropic.Anthropic()
baseline = count(client)
total = count(client, defs) - baseline
print(f"{len(defs)} tools, ~{total} tokens of definitions\n")
# Per-tool breakdown, biggest first
rows = [(count(client, [d]) - baseline, d["name"]) for d in defs]
for tokens, name in sorted(rows, reverse=True):
print(f"{tokens:>6} {name}")
if __name__ == "__main__":
main()
Run it against your server's start command:
python measure_tools.py python -m my_server
python measure_tools.py npx -y @acme/mcp-server
The per-tool numbers include some fixed overhead for "tools are present", so treat them as relative, not exact. What you're looking for is the shape: usually a handful of tools account for most of the total. If you don't use Anthropic models, the same approach works with any provider that exposes a token counter; and as a rough offline proxy, the length of json.dumps(defs) divided by four is in the right ballpark for English text, but don't publish numbers from it.
Also run it against the servers you use, not just the ones you build. It's an eye-opener for deciding what to keep enabled.
The four usual suspects
When we look at a heavy tool list, the weight almost always comes from the same places.
1. One tool per endpoint
Wrapping an OpenAPI spec into MCP tools is the fastest way to ship a server and the fastest way to bloat one. Twenty CRUD endpoints become twenty tools with overlapping descriptions. Models don't think in endpoints; they think in tasks.
2. Descriptions written as documentation
A description has one job: help the model decide whether and when to call the tool. History, caveats for humans, and examples of every parameter belong in your README.
3. Schemas that list everything
Enums with hundreds of values (country codes, product categories, every status your system ever had), deeply nested objects copied from an ORM model, and description fields on every property that restate the property name.
4. Tools nobody calls
Admin and maintenance tools that exist "for completeness". If they're rarely useful in a chat, they shouldn't be in every chat.
Design tools around tasks
Here's the before. Four tools, each with its own description and a schema that repeats the same filters:
@mcp.tool()
def list_invoices(customer_id: str, status: str, page: int = 1) -> dict: ...
@mcp.tool()
def get_invoice(invoice_id: str) -> dict: ...
@mcp.tool()
def search_invoices(query: str, status: str, date_from: str, date_to: str) -> dict: ...
@mcp.tool()
def get_invoice_pdf_url(invoice_id: str) -> str: ...
And the after. One task-shaped tool for finding invoices, one for acting on a specific invoice:
from typing import Literal
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("billing")
@mcp.tool()
def find_invoices(
query: str | None = None,
customer_id: str | None = None,
status: Literal["draft", "open", "paid", "void"] | None = None,
since: str | None = None,
limit: int = 10,
) -> list[dict]:
"""Find invoices by text, customer, status or date (ISO date). Returns id, number, customer, total, status."""
...
@mcp.tool()
def invoice_details(invoice_id: str, include_pdf_url: bool = False) -> dict:
"""Full details for one invoice, optionally with a signed PDF link."""
...
Fewer tools, shorter descriptions, and the model has one obvious choice for "show me unpaid invoices for Acme since March". The descriptions are one sentence each and say what comes back, which helps the model chain calls without guessing.
Trim the schemas
A few rules that consistently shrink schemas without losing anything the model needs:
-
Replace huge enums with a lookup. Instead of 250 country codes in an enum, accept a free-text
countryand resolve it server-side, or expose a smalllookup_codes(kind, query)tool that's only called when needed. -
Flatten. If a tool takes
{"filters": {"date": {"from": ..., "to": ...}}}, considersinceanduntilas top-level strings. -
Drop property descriptions that restate the name.
customer_id: "The ID of the customer"adds tokens and no information. Keep descriptions for things that are genuinely ambiguous, such as units, formats and time zones. - Use sensible defaults so the model doesn't need to reason about optional parameters at all.
Re-run the measuring script after each change. It's the fastest feedback loop you'll get.
Don't expose everything all the time
The spec lets a server declare the listChanged capability for tools and send notifications/tools/list_changed when its list changes. That opens up a useful pattern: start with a small core set, and enable a group of extra tools only when the conversation needs them (for example, after the user picks a project, or through an explicit enable_admin_tools call). Client support for refreshing the list varies, so test this with the clients your users actually run.
The simpler version works everywhere: toolsets chosen at start-up. Let whoever configures the server choose which groups to load:
import os
# Same functions as above, defined without the @mcp.tool() decorator
# so registration can be conditional.
ENABLED = set(os.environ.get("BILLING_TOOLSETS", "read").split(","))
if "read" in ENABLED:
mcp.tool()(find_invoices)
mcp.tool()(invoice_details)
if "write" in ENABLED:
mcp.tool()(issue_refund)
mcp.tool()(void_invoice)
A support engineer gets the read-only set; the finance automation gets both. Smaller list, and a smaller blast radius if a prompt injection ever reaches the model.
Results cost tokens too
Definitions are the fixed cost; results are the variable one. A tool that returns a full ORM dump of 200 rows burns far more context than its definition ever did. Return what the model needs to decide the next step:
- Default
limitto something small (10 is plenty for most chat use) and say how to get more. - Return IDs and key fields, and let a details tool fetch the rest.
- Summarise large text server-side when the full text isn't needed, or return a resource link instead of inlining the content.
A checklist before you ship a server
- [ ] Ran a token measurement and know the total and the top five tools.
- [ ] Tools map to user tasks, not endpoints.
- [ ] Every description is one or two sentences and says what comes back.
- [ ] No enum longer than about a dozen values.
- [ ] Rarely used or dangerous tools sit behind a toolset flag.
- [ ] Results are paginated and trimmed by default.
If you're building your first server and want the end-to-end setup (TypeScript, tool definitions, testing with MCP Inspector), we wrote a longer guide on how to build an MCP server that pairs well with this one.
Wrapping up
Tool definitions are prompt. Treat them with the same care you'd give a system prompt: measure them, cut what doesn't change the model's decisions, and only load what the current job needs. Your users' context windows, and their bills, will thank you.
Question for you: have you measured the token cost of the MCP servers you have enabled right now? Which one surprised you most?
Top comments (0)