Chapter 1 of 8 · 15 min read
Declaring and calling a tool
On this page
A tool is a function on a server that a model is allowed to choose to call in the middle of a conversation. The model never sees the function itself. It is shown a name, a sentence or two describing what the function does, and the shape of the arguments it takes; when a question needs that function, the model emits a request to run it, some program runs it, and the output is placed back into the conversation for the model to answer from. Tools exist because a model on its own can only produce text out of what it absorbed during training, which does not include the status of a particular support ticket as of four minutes ago. A lookup against a tracking system is the only route by which that fact can reach the conversation.
The Model Context Protocol, abbreviated MCP, is the published standard for connecting functions like that one to whatever application is holding the conversation. Anthropic released it in November 2024 to remove a cost that was being paid over and over: every assistant product had its own private format for declaring tools, so a team with N useful integrations facing M applications wrote N × M pieces of glue, and an integration built for one product was worth nothing to the next. Under MCP a capability is exposed once, by a server the team that owns it runs, and any application that speaks the protocol can use it without that team writing anything further. You meet it in practice as the connectors panel in a desktop chat client, as a block in an IDE's configuration file naming a command to launch, or as a URL for a server somebody else hosts, showing a connected server as a green dot in a connection panel.
Between a server registering a tool and a model deciding what to do with it, four messages pass, and all four are readable. That readability is the reason to build the path once by hand rather than to read about it: a green dot says a session is live and says nothing about what was in it, and the difference between those two statements is where an afternoon goes.
Three fields and a function
A tool declaration is smaller than most people expect. MCP asks for a name, a human-readable description, and a JSON Schema describing the input object; everything else — annotations, an output schema, a title — is optional. Beside the declaration sits a handler, an ordinary function that receives the arguments and returns content. Topic 5.10, on tool granularity and surface, covered how to decide what a tool should be; this is the mechanical part of saying so.
import json
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("incident-tools")
INCIDENTS = {
"INC-4471": {
"status": "mitigated",
"severity": 2,
"owner": "payments-oncall",
"opened_at": "2026-03-14T02:11:00Z",
"title": "Card authorisation latency above 900ms p99",
},
}
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="get_incident",
description=(
"Look up one incident by its identifier and return its current "
"status, severity, owning team, title and open time. Identifiers "
"look like INC-4471. Use this before answering any question about "
"a specific incident; do not answer from memory."
),
inputSchema={
"type": "object",
"properties": {
"incident_id": {
"type": "string",
"pattern": "^INC-[0-9]{4}$",
"description": "Incident identifier, for example INC-4471.",
}
},
"required": ["incident_id"],
"additionalProperties": False,
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
record = INCIDENTS.get(arguments["incident_id"])
if record is None:
return [TextContent(type="text", text="No incident with that identifier.")]
return [TextContent(type="text", text=json.dumps(record))]The schema is doing more work here than the type hints are. pattern and
additionalProperties: false are the only things in this file that constrain what arrives at
the handler, because the arguments are produced by a model from the schema and validated
against the schema — the Python signature arguments: dict is not consulted by anything
upstream. That is the first hint of a theme this topic returns to repeatedly: the parts of
your code that feel like the contract are frequently not the contract.
Following that one step at a time is worth the space, because "the schema is the contract"
is easy to agree with and hard to act on. The arguments in a tools/call are produced by a
model, and the only description of the arguments the model was given is the JSON Schema —
the schema is what travels. Whatever validation runs before your handler is a comparison
against that same schema, because it is the only machine-readable statement of the shape
that exists anywhere in the path. The Python signature is a fact about one process on one
machine; nothing serialises it, nothing sends it, and nothing on the far side could read it
if it arrived. So the constraints that hold at run time are exactly the ones written in
inputSchema and no others, and a type hint that disagrees with the schema does not create
a conflict — it creates a hint that is never consulted.
What actually crosses the wire
Start the server, connect a client, and read the messages. They are JSON-RPC 2.0, so each one
is a small object with a method and either params or a result. After the initialize
handshake in which both sides declare capabilities, the client asks for the list:
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "get_incident",
"description": "Look up one incident by its identifier and return its current status, severity, owning team, title and open time. Identifiers look like INC-4471. Use this before answering any question about a specific incident; do not answer from memory.",
"inputSchema": {
"type": "object",
"properties": {
"incident_id": {
"type": "string",
"pattern": "^INC-[0-9]{4}$",
"description": "Incident identifier, for example INC-4471."
}
},
"required": ["incident_id"],
"additionalProperties": false
}
}
]
}
}That listing is the entire public surface of your server as far as the rest of the system is
concerned. It is fetched once when the session connects — on a stdio transport, where the host
launches your server as a child process and exchanges messages over its standard input and
output, the round trip is around 3 to 5 ms, which is why nobody notices it — and it is the
input the host uses to build the tool array in the request it sends the model provider. The
host may re-fetch it if your server sends a notifications/tools/list_changed, but in the
common case it is fetched once and cached for the life of the connection. A listing that was
wrong at the moment it was fetched therefore stays wrong for the whole session, which is a
property worth holding on to: this is a snapshot, not a live query.
That conclusion needs its middle step said out loud, because a server author's mental model
is a running process and a running process gets better over time. The host asks once, at
connect. It keeps the answer, because re-asking on every turn would cost a round trip per
turn to learn something that almost never changes. Nothing in the protocol lets it discover
that the answer has gone stale — the only way it can find out is if the server tells it, with
a notifications/tools/list_changed. So the state of your server at one instant, the
instant the session opened, is the state the model will be reasoning about for as long as
that session lasts. A server that is correct three seconds later is not correct for that
session at all, and no amount of health on the server side will change what the host is
holding.
When the model selects the tool, the selection travels back through the host and arrives at your server as a second message:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {"name": "get_incident", "arguments": {"incident_id": "INC-4471"}}
}{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{"type": "text",
"text": "{\"status\": \"mitigated\", \"severity\": 2, \"owner\": \"payments-oncall\", \"opened_at\": \"2026-03-14T02:11:00Z\", \"title\": \"Card authorisation latency above 900ms p99\"}"}
],
"isError": false
}
}The content array is what the host turns into a tool result in the conversation, and the
model reads it as text. There is no type information in it, no schema on the way back unless
you declared an outputSchema, and no guarantee about how the host formats it before the
model sees it. A JSON string is a common choice because it survives being read as prose, which
matters when the reader is a language model rather than a deserialiser.
Five hops, two of which are not yours
Laid out end to end, the path has five hops, and the middle one leaves your process entirely and becomes an HTTP request to a model provider that has never heard of MCP.
Owning the first, second and fourth of those and owning none of the others is the structural fact that organises the rest of this topic. You control the listing and the handler. The host controls how the listing is rendered into a provider request, whether it asks the user anything before hop four, whether it retries hop four when it fails, and what it does with a result that says something went wrong. The provider controls the selection. When a symptom appears — the wrong tool chosen, the same call issued six times, a write executed twice — locating it is mostly a matter of asking which of these five hops could have produced it, and then reading that hop rather than theorising about it.
The asymmetry is worth stating in its harshest form. The two hops you do not own are the two that decide whether anything you wrote is ever used: how your listing is rendered into a provider request, and whether the model picks your entry out of what it was given. Neither is observable from your server without capturing it, neither is negotiable, and both differ per host. Everything you can change lives in the three fields of the listing and the body of the handler, which is a small surface and the whole of your leverage.
Driving all five hops from one script
The fastest way to make those hops concrete is to play the host yourself for about thirty lines, because a script that does the job of a host has no hidden behaviour in it. The MCP SDK gives you the client half, the provider SDK gives you the model call, and the translation between them — the thing a real host does behind a debug flag — becomes a list comprehension you wrote and can read.
import asyncio
import anthropic
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
PARAMS = StdioServerParameters(command="python", args=["incident_server.py"])
async def main() -> None:
provider = anthropic.Anthropic()
async with stdio_client(PARAMS) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
listing = await session.list_tools() # hops 1 and 2
tools = [
{
"name": t.name,
"description": t.description,
"input_schema": t.inputSchema,
}
for t in listing.tools
]
messages = [{"role": "user", "content": "what's the status of INC-4471?"}]
reply = provider.messages.create( # hop 3
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": reply.content})
for block in reply.content:
if block.type != "tool_use":
continue
outcome = await session.call_tool(block.name, block.input) # hop 4
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": outcome.content[0].text,
}],
})
final = provider.messages.create( # hop 5
model="claude-opus-5",
max_tokens=1024,
tools=tools,
messages=messages,
)
print(final.content[0].text)
asyncio.run(main())Running it prints a sentence reporting that INC-4471 is mitigated, at severity 2, owned by
payments-oncall. The interesting output is not that sentence. It is reply.stop_reason, which
comes back as tool_use rather than end_turn, and reply.usage, which on this tiny example
reports 431 input tokens against 66 output tokens for the first call and 512 against 58 for
the second — the listing is being re-sent on the second call because the provider API is
stateless and the tool array travels with every request in the conversation. The whole run
takes about 2.6 seconds wall-clock on a warm connection, of which roughly 2.5 is the two
provider round trips and the rest is everything MCP does.
Those numbers reconstruct each other, and reconstructing them is the fastest way to stop thinking of the provider request as a black box. Add the first call's input to the first call's output — four hundred and thirty-one plus sixty-six — and you have almost exactly the second call's input of five hundred and twelve. That is not a coincidence and it is not the tool array being counted twice: the second request has to contain everything the first one did, because the API is stateless, plus the assistant's reply, because a conversation includes what the assistant said, plus the tool result. The handful of tokens left over after that addition is the tool result itself, which is one short JSON object. Anything that makes a conversation longer therefore makes every subsequent turn more expensive, and the tool array sits at the front of all of them.
The wall-clock figures split the same way and are worth doing as a subtraction. Take the two
provider round trips off the total and what remains for the entire MCP path — launching the
server, the handshake, fetching the listing, one tools/call and its result — is about a
tenth of a second. The listing round trip is the 3 to 5 ms quoted above, so it is a small
fraction of that remainder and a very small fraction of the whole. This is why nobody
notices the protocol and why almost every latency complaint about a tool-using assistant is
a complaint about a model call.
The tool_use_id deserves a moment because it is the join key that makes the fifth hop work
at all. The provider assigns it when the model selects a tool, the host has to carry it across
the MCP call, which knows nothing about it, and quote it back on the result; get it wrong and
the provider rejects the request rather than silently misattributing anything. MCP has its own
correlation identifier, the JSON-RPC id in tools/call, and the two live in different
namespaces and never meet. A host is the thing that holds both.
There being two of them is a consequence of the shape rather than an accident anybody could
tidy away. Each side of the host runs its own request-and-response protocol, and each needs
to match an answer to the question that produced it: the provider has to know which
tool_use block a result belongs to, and MCP has to know which tools/call a JSON-RPC
response belongs to. Neither protocol has heard of the other, so neither can reuse the
other's identifier, and neither can be told to. The host is the only component that sees
both conversations at once, which makes carrying the correspondence between them the host's
job by elimination rather than by design. The practical consequence is that a mismatch here
is loud — the provider rejects a tool_use_id it did not issue — while a mismatch in the
MCP id is quiet, and both are failures of the same joining step.
Which hop a symptom has to live in
The value of having built the path by hand is that symptoms stop being ambiguous. Each of the recurring complaints about tools over MCP can only be produced at one or two places in the sequence, and each of those places has an artefact you can capture rather than reason about.
| Symptom | Hop it has to live in | The artefact that settles it |
|---|---|---|
| Server logs a registered tool; the model says it has no access | 2 | the tools/list result on the wire, captured at session start |
| The tool lists, the host forwards it, the model never picks it | 3 | the tools array in the provider request |
| Arguments arrive with a field the handler never expected | 3 | the schema as forwarded, next to the tool_use block |
| The same call arrives six times with identical arguments | 4 and 5 | the handler's access log plus the result content |
| One user-visible action produced two writes | 4 | the handler's log, timestamped, with the JSON-RPC ids |
| The model answers as though the tool returned nothing | 5 | the tool_result content as the host assembled it |
The green dot in that story was doing most of the misleading, and it was not lying. What a
connection indicator asserts is that a transport session exists: the process launched, the
handshake completed, and messages can pass. What it gets read as is that the server's tools
are available to the model, which is a claim about the content of one message rather than
about the connection carrying it. Those two come apart in exactly the case that occurred,
and they come apart silently, because an empty tool list is a perfectly valid tools/list
result. A server with nothing to offer and a server whose offer never arrived look identical
from the panel, which is the general reason a status indicator cannot substitute for an
artefact.
Endpoints, resources and prompts
An MCP tool is not an HTTP endpoint with a different envelope, even when it wraps one. Endpoints are chosen by code that read the documentation once, at development time; tools are chosen per call by a model reading the description at inference time, which makes the description a runtime input rather than reference material. That difference is why an endpoint with a terse one-line summary is fine and a tool with the same summary frequently is not.
Tools are also not the only primitive MCP defines, and the distinction between them is about who does the choosing. Tools are model-controlled: the model decides to invoke one, which is why they carry side effects and why the rest of this topic is about safety. Resources are application-controlled: the host decides what to read and attach, typically a file or a record identified by URI, and the model never triggers the read. Prompts are user-controlled: they surface in the host as slash commands or menu entries that a person picks deliberately. If the thing you are building should only ever run when a human asks for it by name, a tool is the wrong primitive, and no amount of careful wording in the description will make the model's judgement about when to call it as reliable as a person clicking a menu item.
Finally, the client in this picture is not the model. It is a piece of the host application — a library that speaks JSON-RPC, manages the connection, and decides what to do with what it receives. When a chapter here says "the client forwards", it means a program made a choice, and programs made by different vendors make different choices.
Three fields, one handler, five hops. That is the whole mechanism, and building it once by hand is worth more than reading about it because it converts every later question from a theory about the system into a request for a specific artefact: the listing on the wire, the tools array in the provider request, the handler's access log, the tool result as the host assembled it.
The habit that follows is small and unusually cheap. Before proposing a cause, name the hop it would have to live in, then produce the artefact from that hop. A team without the habit has as many theories as it has engineers, and each theory concerns a part of the system nobody has looked at; a team with it has one JSON object and a much shorter conversation. The path is about an hour to build the first time and it does not have to be built again, because the script is the same for every server you will ever connect.
The hop worth looking at next is the third one, because it is the one that leaves your process and the one nobody on either side of it can see without capturing it. The gap between what you wrote on the server and what the model was actually given is where the most expensive misunderstanding in this whole area lives.
Tools over MCP — read the current version at https://daviddominguez.dev/learn/en/tools-over-mcp/declaring-and-calling-a-tool