MCP Tools: Giving Agents the Power to Act
Tools are the part of the Model Context Protocol that lets a model do things instead of just talk about them, and their design is what separates an agent that helps from one that flails.
Of the three capabilities a Model Context Protocol (MCP) server can expose, tools are the ones that change the world. A resource lets a model read; a tool lets it act — create an issue, run a query, send a message, book a meeting. Because tools carry side effects and because the model decides when to call them, they deserve careful design. This post covers what a tool is, how it is defined, how discovery and invocation work on the wire, how results and errors flow, and how to design tools a model can actually use well.
What a tool is
A tool in MCP is a named, model-controlled action the server offers. “Model-controlled” is the key phrase: unlike a resource (which the application decides to load) or a prompt (which the user invokes), a tool is something the model elects to call in the middle of its reasoning. You expose the capability; the model chooses whether and when to use it.
That control model is powerful and is exactly why tools need guardrails. A tool that deletes records or spends money should not fire silently because the model thought it was a good idea — hosts typically gate consequential tools behind user approval, which we will connect to tool annotations below.
Tool definitions: name, description, schema
Every tool is described by three things, and the model sees all of them:
- a name — a stable identifier like
create_issue; - a description — natural language explaining what the tool does and when to use it;
- an input schema — a JSON Schema object describing the parameters.
These are not bureaucratic metadata; they are the model’s entire basis for deciding whether to call the tool and how to fill in the arguments. A vague description or a loose schema produces wrong calls. “Send a message” is worse than “Send a Slack message to a channel or user; use for team notifications, not for email.” args: object is worse than a schema that names each field, types it, marks which are required, and constrains enums. The schema is a contract and a prompt.
Discovery and invocation on the wire
Two methods drive tools. The client discovers what exists with tools/list:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "get_forecast",
"description": "Return a short weather forecast for a city.",
"inputSchema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" },
"days": { "type": "integer", "minimum": 1, "maximum": 7, "default": 1 }
},
"required": ["city"]
}
}
]
}
}
When the model decides to use it, the client sends tools/call with the chosen arguments:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": { "name": "get_forecast", "arguments": { "city": "Pune", "days": 2 } }
}
The server runs the tool and returns its output as content the model can read. A server may also advertise that its tool list can change and emit a notifications/tools/list_changed notification so clients re-list — useful when tools appear or disappear at runtime.
Results and errors
A tool call returns content — commonly text, but also structured data or other content types — which the client feeds back to the model. The important subtlety is how failure is represented.
There are two distinct failure modes, and conflating them is a classic bug:
- A protocol error means the request itself was invalid — unknown tool name, malformed params. This is a JSON-RPC error and typically indicates a client or wiring bug.
- A tool execution error means the tool ran but the operation failed — the weather API returned a 404, the query timed out, the file was missing. This should come back as a normal tool result flagged as an error, not as a JSON-RPC protocol error.
Why the distinction matters: the model needs to see a tool failure to react to it — retry with different arguments, apologize, try another approach. If you bury a 404 in a protocol error, the model often never sees it and the agent stalls. So report execution failures as visible, in-band error results with a helpful message (“City ‘Xyz’ not found; check spelling”), and reserve protocol errors for genuinely malformed calls.
Defining a tool with the Python SDK
The official Python SDK’s FastMCP makes tool definition almost invisible — you write a normal function, and the decorator derives the schema from its type hints and docstring:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather")
@mcp.tool()
def get_forecast(city: str, days: int = 1) -> str:
"""Return a short weather forecast for a city.
Args:
city: City name, e.g. "Pune".
days: Number of days, 1-7.
"""
if not city.strip():
raise ValueError("city must not be empty")
# ... call the underlying weather API ...
return f"{city}: sunny for the next {days} day(s)."
The city: str and days: int = 1 hints become the input schema (with days optional because it has a default); the docstring becomes the description. Raising a ValueError for bad input is surfaced to the model as a readable error rather than crashing the session. This is the payoff of a good SDK: the protocol details recede and you write ordinary, well-documented functions.
Annotations and approval
Tools can carry hints about their nature — for example, whether they are read-only or potentially destructive, and whether they are idempotent. Hosts use these hints to decide how much friction to add. A read-only get_forecast can run freely; a delete_repository should trigger an explicit “are you sure?” to the user. Annotations are advisory, not enforcement — never rely on them for security — but they let a well-behaved host apply human-in-the-loop approval where it matters without prompting on every trivial read.
Designing tools a model can use
A few principles separate tools that work from tools that frustrate:
- Keep each tool narrow. One clear job beats a Swiss-army tool with a
modeparameter the model gets wrong. - Name for intent.
search_customerstells the model when to reach for it;db_querydoes not. - Validate inputs and return helpful errors. The error message is feedback the model will act on — make it actionable.
- Prefer explicit schemas. Enums, ranges, and required fields reduce malformed calls dramatically.
- Mark destructive tools so the host can gate them.
Tools are where MCP earns its keep. Treat their descriptions and schemas as part of the prompt, because to the model, that is exactly what they are.
Key takeaways
- Tools are model-controlled actions with side effects; the model chooses when to call them, which is why consequential tools need approval.
- A tool is a name, a description, and a JSON Schema input — all visible to the model and all effectively part of the prompt, so write them precisely.
tools/listdiscovers tools andtools/callinvokes them; servers can signal changes with alist_changednotification.- Report execution failures as in-band error results the model can see and react to; reserve JSON-RPC protocol errors for malformed calls.
- With the Python SDK’s
FastMCP, a decorated function’s type hints and docstring become the tool’s schema and description.