MCP (Model Context Protocol) · lesson 3 of 13
Beginner
4Intermediate
4Advanced
5Also part of MCP (Model Context Protocol)
5Also part of: LLM Engineering AI Agents
MCP Tools: Schemas, Results and Errors
See the inputSchema MCP builds from Python type hints, return structured output, and tell tool errors from protocol errors, all with real printed results.
By SkillByExample · Published
What a Host Learns From a Tool
A host shows the model each tool’s name, description and argument schema, and the model writes the arguments. Every detail you put in the type hints becomes part of that contract, and the SDK enforces it on every call. These examples use the in-memory client from the first lesson, so each one is a single file.
The Schema Comes From Type Hints
Pydantic’s Field adds a description and a constraint; a default value makes an argument optional.
# schema.py - print the inputSchema the SDK builds from type hints
import asyncio
import json
from typing import Annotated
from pydantic import Field
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("shop")
@mcp.tool()
def get_order(
order_id: Annotated[str, Field(description="Order id like A1042", pattern=r"^A\d{4}$")],
verbose: bool = False,
) -> str:
"""Look up one order by id."""
return f"{order_id}: paid"
async def main():
async with Client(mcp) as client:
tool = (await client.list_tools()).tools[0]
print(tool.name, "-", tool.description)
print(json.dumps(tool.input_schema, indent=2))
asyncio.run(main())
$ python schema.py
get_order - Look up one order by id.
{
"type": "object",
"properties": {
"order_id": {
"description": "Order id like A1042",
"pattern": "^A\\d{4}$",
"title": "Order Id",
"type": "string"
},
"verbose": {
"default": false,
"title": "Verbose",
"type": "boolean"
}
},
"required": [
"order_id"
],
"title": "get_orderArguments"
}
The docstring became the description, and order_id’s Field text became the argument’s own description. A model reads both when it decides what to send, so write them for that reader: say what the argument is and give an example value. verbose has a default, so it’s left out of required, and its default is advertised. The pattern isn’t just documentation. The SDK checks every call against this schema, as the errors section shows.
Structured Output
Return a Pydantic model and the client gets JSON it can use directly, plus a schema describing it.
# structured.py - a Pydantic return type becomes structured output
import asyncio
from typing import Literal
from pydantic import BaseModel
from mcp import Client
from mcp.server import MCPServer
mcp = MCPServer("shop")
class Order(BaseModel):
id: str
status: Literal["paid", "shipped"]
total: float
@mcp.tool()
def get_order(order_id: str) -> Order:
"""Look up one order by id."""
return Order(id=order_id, status="paid", total=59.9)
async def main():
async with Client(mcp) as client:
tool = (await client.list_tools()).tools[0]
print("outputSchema required:", tool.output_schema["required"])
result = await client.call_tool("get_order", {"order_id": "A1042"})
print("structured:", result.structured_content)
print("text:", result.content[0].text.replace("\n", " "))
asyncio.run(main())
$ python structured.py
outputSchema required: ['id', 'status', 'total']
structured: {'id': 'A1042', 'status': 'paid', 'total': 59.9}
text: { "id": "A1042", "status": "paid", "total": 59.9 }
The listing now has an outputSchema built from Order, so a host knows the result’s shape before calling. structured_content is the model’s fields as a dict, with no {"result": ...} wrapper, because a Pydantic model is already an object. The SDK also serialised the same data as indented JSON text in content, for clients and models that read only content blocks. A function returning a plain dict gets the text but no outputSchema or structured_content, because a bare dict says nothing about its keys. Return a model whenever a program, not just a model, will read the result.
Four Ways a Call Fails
Some failures the model should see and fix; some it shouldn’t see at all. This tool raises a deliberate error for id 0 and crashes for id 13.
# errors.py - four ways a tool call fails, as the client sees them
import asyncio
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("shop")
@mcp.tool()
def get_order(order_id: int) -> str:
"""Look up one order by id."""
if order_id == 0:
raise ToolError("no order with id 0")
if order_id == 13:
raise RuntimeError("db at 10.0.0.5 refused password 'hunter2'")
return f"order {order_id}: paid"
async def main():
async with Client(mcp) as client:
for name, args in [("get_order", {"order_id": 0}), ("get_order", {"order_id": 13}),
("get_order", {"order_id": "x"}), ("delete_order", {"order_id": 1})]:
r = await client.call_tool(name, args)
print(r.is_error, "|", r.content[0].text.splitlines()[0])
asyncio.run(main())
$ python errors.py 2>/dev/null
True | Error executing tool get_order: no order with id 0
True | Error executing tool get_order
True | Error executing tool get_order: 1 validation error for get_orderArguments
True | Unknown tool: delete_order
All four are ordinary results with is_error set to True, not exceptions. That’s deliberate: a host passes the text back to the model, which can correct itself.
ToolErroris a failure you expected. Its message reaches the client, so make it something the model can act on.- Any other exception is treated as a crash. The client sees only
Error executing tool get_order. The database address and password in theRuntimeErrordidn’t leak into the model’s context. - Bad arguments never reach your function.
"x"failed theintegerschema, and the full message names the field (order_id) and the reason. - An unknown tool name is also a tool error in this SDK, so a model that guesses a name learns it was wrong.
The 2>/dev/null hid the server’s side of those calls. Here it is, with the traceback’s indented frames filtered out:
$ python errors.py 2>&1 >/dev/null | grep -v '^ '
Tool 'get_order' failed: 'Error executing tool get_order: no order with id 0'
Tool 'get_order' raised an unexpected exception
Traceback (most recent call last):
RuntimeError: db at 10.0.0.5 refused password 'hunter2'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
mcp.server.mcpserver.exceptions.UnexpectedToolError: Error executing tool get_order
Tool 'get_order' rejected arguments: ['order_id']
Tool 'delete_order' failed: 'Unknown tool: delete_order'
Only the crash gets a traceback, and the original RuntimeError is kept in it, so you can still debug. Expected failures get one log line each. The split is the point: the operator sees everything, the model sees only what you chose to tell it.
Protocol Errors Are Exceptions
MCPError is the one exception that isn’t turned into a tool result. It becomes a JSON-RPC error response instead.
# protocol_error.py - an MCPError is a JSON-RPC error, not a tool result
import asyncio
from mcp import Client, MCPError
from mcp.server import MCPServer
mcp = MCPServer("shop")
@mcp.tool()
def get_order(order_id: int) -> str:
"""Look up one order by id."""
raise MCPError(-32602, "order lookups are disabled")
async def main():
async with Client(mcp) as client:
try:
await client.call_tool("get_order", {"order_id": 1})
except MCPError as e:
print("MCPError", e.error.code, e.error.message)
asyncio.run(main())
$ python protocol_error.py
MCPError -32602 order lookups are disabled
call_tool raised instead of returning, with the JSON-RPC code -32602 (“invalid params”). A host typically surfaces a protocol error to its own code or the user rather than to the model, so the model can’t retry. Keep MCPError for things that are wrong with the request itself, and use ToolError for anything the model could fix.
Annotations Are Hints
A title is a human-readable name for a UI. Annotations describe how the tool behaves, so a host can decide whether to ask the user first.
# annotations.py - a title and behaviour hints for hosts
import asyncio
from mcp import Client, types
from mcp.server import MCPServer
mcp = MCPServer("shop")
@mcp.tool(title="Cancel an order",
annotations=types.ToolAnnotations(destructive_hint=True, idempotent_hint=True))
def cancel_order(order_id: int) -> str:
"""Cancel an unshipped order."""
return f"order {order_id} cancelled"
async def main():
async with Client(mcp) as client:
tool = (await client.list_tools()).tools[0]
print("title:", tool.title)
print(tool.annotations.model_dump(by_alias=True, exclude_none=True))
asyncio.run(main())
$ python annotations.py
title: Cancel an order
{'destructiveHint': True, 'idempotentHint': True}
Python’s destructive_hint goes over the wire as destructiveHint. It says the tool may change or delete things, and idempotentHint says calling it twice with the same arguments has no extra effect. The other two hints are readOnlyHint and openWorldHint (the tool talks to systems outside the server). Nothing enforces any of them: a server can mark a destructive tool read-only. A host should trust annotations only from servers it trusts, as the MCP security lesson shows.
Practice
- In
schema.py, add alimitargument that must be between 1 and 100 and defaults to 10. What does its schema entry look like, and what happens when a client sends0? - Change
structured.py’s tool to returnlist[Order]. Predictstructured_contentbefore running it. - In
errors.py, the crash branch hides the real cause from the model. Rewrite it so the model learns the order service is unavailable, without revealing the address or password. - Mark
get_orderinstructured.pyas read-only and give it the title “Look up an order”.
Solution 1
limit: Annotated[int, Field(ge=1, le=100)] = 10,
The entry is {"default": 10, "maximum": 100, "minimum": 1, "title": "Limit", "type": "integer"}, and limit stays out of required. A call with 0 returns is_error=True with “Input should be greater than or equal to 1” in the message; the function never runs.
Solution 2
@mcp.tool()
def get_order(order_id: str) -> list[Order]:
"""Look up orders by id."""
return [Order(id=order_id, status="paid", total=59.9)]
A list isn’t an object, so it’s wrapped: {'result': [{'id': 'A1042', 'status': 'paid', 'total': 59.9}]}. Remember to change output_schema["required"] in the print too, which is now ['result'].
Solution 3
Catch the failure and raise a ToolError with a message you chose:
if order_id == 13:
raise ToolError("order service unavailable, try again later")
The client now sees Error executing tool get_order: order service unavailable, try again later. Log the real cause yourself (to stderr or a logger) before raising, because a ToolError is logged as one line without a traceback.
Solution 4
@mcp.tool(title="Look up an order", annotations=types.ToolAnnotations(read_only_hint=True))
Import types with from mcp import Client, types. The listing then carries {'readOnlyHint': True}. It’s still only a claim the server makes.