Chapter 3 of 8 · 16 min read
Translating MCP tools for the model
On this page
The sentence you will hear more than any other about this protocol is that once we're on MCP, the model talks to our servers directly. It is wrong at every layer, and the reason it matters is that people act on it: the most direct consequence is a pull request that deletes the tool-calling plumbing on the grounds that MCP has replaced it. What is actually true is narrower and more useful, and it starts with what a model is capable of.
A model produces tokens. It has no sockets, no subprocesses, no ability to make a request
to anything, and no knowledge that MCP exists. What it has is the tool-calling mechanism
already in use before any of this: a tools array in the provider request describing what
is available, and a structured tool-use block in the response naming one and supplying
arguments. That mechanism is untouched by MCP. What changes is where the contents of the
tools array came from.
The step under "a model produces tokens" is worth taking slowly, because everything else in this chapter is a consequence of it. A model is a function from a sequence of tokens to a probability distribution over the next token, and running it consists of sampling from that distribution repeatedly. Nothing in that description contains an action. There is no point in the computation at which a file descriptor could be opened, because the computation is arithmetic over numbers and its only output is more numbers. So when a model "calls a tool", what has physically happened is that it emitted a particular sequence of tokens that the provider's API parses into a structured block — a name and an argument object — and then something outside the model reads that block and does the work. The tool-use block is a request written in text, not an action taken. Which means the component that performs the action is by definition not the model, and once that is settled, the only remaining question is which component it is.
Three layers, and which one speaks the protocol
Your client is the only component in that picture that speaks MCP, and it is also the only
component that speaks to the provider. It sits between two protocols and translates. On
one side it holds sessions to servers and issues tools/list and tools/call; on the
other it builds an ordinary provider request and runs the ordinary loop. The model sees a
flat list of tool definitions and has no way to tell whether they were typed into your
source file by hand last year or read off a socket four milliseconds ago.
| Knows the tool name | Knows the schema | Knows the server | Speaks MCP | |
|---|---|---|---|---|
| Model | yes | yes | no | no |
| Provider API | yes, as opaque text | yes, as opaque JSON | no | no |
| Your client | yes | yes | yes | yes |
| MCP server | yes | yes | itself only | yes |
The translation is unglamorous and short, which is the best evidence that nothing deep is happening. Each discovered tool carries three fields — a name, a description and an input schema — and the provider request wants the same three under different key names:
mcp_tools = await session.list_tools()
tools = [
{
"name": t.name,
"description": t.description,
"input_schema": t.inputSchema, # provider calls it input_schema
}
for t in mcp_tools.tools
]
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
tools=tools,
messages=messages,
)Then the dispatch side, which is the half that gets deleted by anybody who believes the model is talking to the server:
if resp.stop_reason == "tool_use":
block = next(b for b in resp.content if b.type == "tool_use")
session = sessions[server_of[block.name]] # which server owns it
result = await session.call_tool(block.name, block.input)
messages.append({"role": "assistant", "content": resp.content})
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": result.content[0].text,
}],
})Compare that with the pre-MCP version of the same agent and the only genuine difference is
two lines: the tool list is built from list_tools() instead of a literal, and dispatch
goes through session.call_tool instead of a Python dict of function references.
Everything about the loop — the stop reason, the tool-use block, the tool-result message,
the re-send — is identical, because it belongs to the provider's API and not to MCP.
That server_of lookup is worth a sentence, since it is a thing MCP does not give you.
Tool names are scoped to a server, so two servers may both expose search, and the flat
provider array cannot hold both. Clients solve this by prefixing — tickets__search,
docs__search — and stripping the prefix before the tools/call. Nothing in the protocol
mandates a scheme, so this is one of the few places where clients genuinely differ from
one another.
The collision is forced rather than unlucky, and seeing why makes the prefix look less like
a hack. Tool names are chosen by server authors, who cannot see each other and have no
shared registry to check against, so nothing prevents two of them from picking search —
and search is close to the most likely name either would pick. On the wire that is fine,
because a tools/call travels down one session to one server, and the session is what
disambiguates. The provider array has no such structure: it is a flat list of names, and a
tool_use block coming back names one string with nothing attached saying where it came
from. So the client has to encode the server identity into the only field that survives the
round trip, which is the name itself, and then undo that encoding before sending the call
onward. All the transformations along that path are reversible, which is exactly why
forgetting to reverse one produces a failure with no diagnosable signature: nothing was
corrupted, nothing was lost, and a name simply arrived somewhere still wearing a prefix it
should have taken off.
The request that leaves your client is therefore an ordinary provider request carrying a
slightly odd-looking tools array. With a ticketing server and a docs server connected,
the body posted to the Messages API is this, and nothing in it mentions MCP:
{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"tools": [
{
"name": "tickets__search",
"description": "Search the support ticket database by free text. Returns at most 20 tickets, newest first.",
"input_schema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"status": { "type": "string", "enum": ["open", "pending", "closed"] }
},
"required": ["query"]
}
},
{
"name": "docs__search",
"description": "Full-text search over internal engineering documentation.",
"input_schema": {
"type": "object",
"properties": { "q": { "type": "string" } },
"required": ["q"]
}
}
],
"messages": [{ "role": "user", "content": "why did checkout 5xx last night?" }]
}Both entries came off a socket a few hundred milliseconds earlier and neither says so. The
input_schema value is whatever JSON Schema the server declared, forwarded without
inspection, which is why a server that publishes something a provider's validator rejects
— a $ref pointing at an external document, a draft keyword the provider does not accept
— produces a 400 on a request your client assembled out of data it never checked.
Sanitising discovered schemas before they enter the array is client work, and no SDK does
it for you by default.
Prefixes, and definitions that outlive the list
The path a tool name walks is worth holding as a shape, because each place it is transformed is a place it can be transformed inconsistently.
A client that prefixes on the way out and forgets to strip on the way back sends a
tools/call for tickets__search, and the server, which has never heard of any prefix,
refuses a name it published itself:
{"jsonrpc":"2.0","id":9,"error":{"code":-32602,"message":"Unknown tool: tickets__search"}}The signature of that failure in a log is distinctive: discovery succeeded, the model selected a tool that genuinely exists, and the server rejected its own name. It deserves an assertion at the dispatch site rather than a comment, because the round trip through the provider is exactly where the prefix becomes invisible, so neither half of the system has a unit test that can catch it.
The other quiet failure is drift between the list and the transcript. A server may send
notifications/tools/list_changed mid-session, and a well-behaved client re-lists and
rebuilds the array, which leaves the messages already in the conversation untouched. A
model that saw deploy_service two turns ago can still emit a tool_use for it after
that tool has gone, because the transcript is the only memory it has and nothing retracts
a definition from a turn already sent. The client is then holding a tool-use block it
cannot dispatch, and the recovery that keeps the conversation coherent is a tool_result
with is_error set and text saying the tool is no longer available, which gives the model
something to act on. Dropping the block or raising to the caller leaves an unanswered
tool_use in the transcript, which most providers reject outright on the next request.
What the tools array costs per request
Once the translation is clear, an operational consequence follows immediately and catches
teams by surprise. The tools array is part of the request body. It is serialised,
tokenised and charged for on every single call to the provider, including the second and
third turns of a tool-using conversation, when the model already committed to a tool two
turns ago and the definitions are pure overhead.
Connect to three MCP servers exposing forty tools between them and you are shipping forty names, forty descriptions and forty JSON Schemas in every request. A modest tool definition — a two-sentence description and four parameters — runs around 110 to 150 tokens once serialised; a fat one with an enum of twenty values and nested objects is 400 or more. Take 130 tokens as a working average and forty tools is roughly 5,200 tokens of tool definitions before your system prompt, before the conversation, before the documents you retrieved.
Multiplication is the right operation there for a reason worth stating, because it is the same reason the number cannot be argued down. Each tool definition is serialised independently into the array, and no part of one definition is shared with any other — two tools that happen to have similar descriptions still ship both descriptions in full, because JSON has no mechanism for saying "as above". A common misunderstanding here is that the provider stores your tool list somewhere and you are sending a reference to it; there is no such store, and the array is transmitted in full on every request. So the cost is the sum of forty independent costs, and a sum of forty roughly equal terms is forty times one of them. Thirteen tens is a hundred and thirty, so forty times a hundred and thirty is four thousand plus twelve hundred, which is the figure above. Doing the same with the six-tool server gives six times a hundred and thirty, which is the other number in the table below.
| 6 tools, 1 server | 40 tools, 3 servers | |
|---|---|---|
| Definition tokens per request | ~780 | ~5,200 |
| Input cost per request at $3/MTok (per million input tokens) | $0.0023 | $0.0156 |
| Over 200k requests/month | $470 | $3,120 |
| Turns in a 4-turn tool conversation | paid 4× | paid 4× |
Both money rows come out of the token counts by one operation each, and walking them is what stops the table reading as an assertion. A price quoted per million input tokens becomes a price per token by dividing by a million, so the cost of a request's tool array is its token count divided by a million, times the per-million rate. Five thousand two hundred tokens is a bit over five thousandths of a million, and a bit over five thousandths of three dollars is a bit over a cent and a half — which is the fat column's per-request figure. The monthly row is then that per-request figure times the number of requests, and two hundred thousand requests at a shade over one and a half cents each is a little over three thousand dollars.
The lean column is where the rounding is worth watching, because doing it in the wrong order gives the wrong answer. The per-request cell is quoted to four decimal places, and multiplying that rounded cent figure by two hundred thousand lands ten dollars below the monthly cell. The monthly cell is right and the discrepancy is the rounding: seven hundred and eighty tokens against a million, times three dollars, is very slightly more than the displayed per-request price, and that sliver is invisible per request and worth ten dollars a month once multiplied out. The general habit this teaches is to carry the unrounded figure through the multiplication and round at the end, rather than rounding first and multiplying a rounded number by two hundred thousand.
The last row is the one people miss when they price this. A tool-using conversation sends
the whole request again on each turn, so a four-turn exchange pays for the definitions four
times even though the model committed to a tool on turn one and the remaining three turns
are reading results. The money is annoying and survivable, and prompt caching — the
provider storing an unchanged prefix of your request so that resending it is billed at a
reduced rate — pushes it down substantially when the tool list is stable, since a cached
definition block reads at a fraction of the input rate. The accuracy cost is the one that
actually damages the agent. Tool selection is a discrimination problem, and forty candidates where four are
relevant is a harder problem than four candidates. The failure is not a crash; it is a
model that picks list_projects when it wanted list_issues because both descriptions
mention "project", and a user who reports that the assistant "gets confused sometimes".
Prompt caching is where a reader most often decides the problem is solved, and it is worth
being exact about which half of the problem it touches. Caching works by the provider
recognising that a prefix of your request is byte-for-byte what it saw last time, so it can
reuse the computation instead of redoing it. Two conditions are hiding in that sentence. It
has to be a prefix, so the tool array only qualifies while it sits ahead of everything that
changes between turns. And it has to be unchanged, so a server sending
notifications/tools/list_changed and a client dutifully rebuilding the array invalidates
the cache for every conversation in flight — the definitions did not merely change, the
bytes did, and the cache is keyed on bytes. Both conditions are satisfiable and neither is
automatic. What no version of caching touches is the second cost above: the model still
reads all forty definitions when it chooses, whether or not the provider had to recompute
them, so caching buys back money and buys back nothing at all of the accuracy.
The three ways to cut the list down are not interchangeable, and each fails in its own way:
| Where it is decided | A 40-tool agent becomes | What it costs | |
|---|---|---|---|
| Connect to fewer servers | client config, per agent | ~8 tools, ~1,000 tokens | the agent cannot do the excluded jobs at all |
| Per-server allow-list | client config, per server | ~15 tools, ~2,000 tokens | a list that drifts as servers add tools |
| Search-and-invoke facade | a gateway or server you run | 2 tools, ~300 tokens | an extra hop, and a retriever that can miss the right tool |
The token column in that table is the same multiplication run three times, which is what makes the three options comparable at a glance. Eight tools at the working average is a little over a thousand tokens, fifteen is a little under two thousand, and the facade is two tools whose definitions are fatter than average because each has to describe a search over a whole catalogue rather than one job — so it lands nearer three hundred than the two-tool average would suggest. Reading down that column is reading the definition count scaled by one constant, which is the useful property: whatever you do to the list, its cost moves in proportion to how many entries survive, and nothing else in the row matters to the token bill.
The first is blunt and free, the second needs maintaining against catalogues you do not control, and the third moves the discrimination problem from the model to a retrieval step, where it becomes measurable and also becomes yours to tune. All three are decided in the client, which is the point: an agent has a job, and the tools that job needs are usually a handful. Configuring a support agent with the ticketing server and not with the deploy server and the analytics server is a config-file edit that removes thirty tool definitions from every request it ever sends.
Resources, prompts, and the client that is not a pipe
Saying that MCP does not replace tool calling is not saying that MCP is a thin skin over
it. Discovery at runtime is a real capability that a hand-written tools array does not
have, and it is what lets a client work with a server it has never seen. The correction is
narrower: it names which component holds that capability, and the answer is your client,
which is also the component that owns every decision about what reaches the model.
It also does not follow that MCP servers are only tools. The protocol defines resources,
which a client can read and place in context without a tool call at all, and prompts,
which are server-supplied templates a user can invoke directly. Both bypass the tools
array entirely, and both are handled by the client rather than by the model, which is the
same architecture one layer over.
The thing to resist is the opposite overcorrection, which is treating the client as a dumb pipe. It is doing prefixing, filtering, dispatch, error mapping and the entire lifecycle of every server session, and when a tool call fails it is the only component in a position to know why. Where a single agent genuinely needs breadth, clients increasingly support per-server tool allow-lists, so you connect to a server and expose eight of its twenty-two tools; the newer pattern of exposing a search-and-invoke facade over a large catalogue is aimed at the same problem, and is still moving fast enough that it is not worth building yourself.
Only the source of the definitions moved. That is the whole of the correction, and holding it makes two otherwise puzzling things obvious. A working system still needs its tool-calling loop, because that loop is the provider's mechanism and MCP never touched it; deleting it does not migrate an agent to anything, it removes the agent's ability to act. And a client connected to three servers is shipping their entire catalogue in every request it sends, because the array the model reads is assembled locally out of whatever discovery returned, by code that has no opinion about how much of it the agent will actually use.
Both of those are the same fact seen from two sides, which is that the client is the only component with a view of both protocols and therefore the only one that can make a decision. It decides which servers to connect to, which of their tools survive into the array, how names are disambiguated, what happens to a tool-use block for a tool that has since disappeared, and what a failed call looks like by the time the model reads it. None of those decisions can be delegated to the model, which cannot see a server, or to the server, which cannot see the model.
What MCP is and why it exists — read the current version at https://daviddominguez.dev/learn/en/what-mcp-is-and-why-it-exists/mcp-servers