Tools are the primitive most people mean when they say "MCP lets the model do things."
Minimal tool definition
{
"name": "get_weather",
"description": "Get current weather for a given city.",
"inputSchema": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, e.g. 'Austin'" }
},
"required": ["city"]
}
}
Server-side implementation (Python SDK example)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("weather-server")
@mcp.tool()
def get_weather(city: str) -> str:
"""Get current weather for a given city."""
data = fetch_weather(city)
return f"{city}: {data.temp_f}F, {data.condition}"
The SDK derives the JSON Schema from the function signature and docstring automatically, and exposes it through tools/list without you hand-writing the schema.
The two-step flow
- Discovery: client calls
tools/list, gets back every tool's name/description/schema. This, and nothing else, is what the model has to decide when to use a tool. - Invocation: the model decides to use one, the host issues
tools/callwith{"name": "get_weather", "arguments": {"city": "Austin"}}, and the server executes the actual function, returning aCallToolResult.
Related Resources
The inputSchema is the contract. It says what the model can produce, and what your function actually needs.
A richer example
{
"name": "create_issue",
"description": "Create a GitHub issue in the given repository.",
"inputSchema": {
"type": "object",
"properties": {
"repo": { "type": "string", "description": "owner/repo, e.g. 'acme/webapp'" },
"title": { "type": "string", "minLength": 1 },
"labels": { "type": "array", "items": { "type": "string" } },
"priority": { "type": "string", "enum": ["low", "medium", "high"] }
},
"required": ["repo", "title"]
}
}
Why JSON Schema specifically
- It's the schema language most LLM providers already expect for function-calling. A host can pass an MCP tool's
inputSchemastraight through to the model's API with little or no transformation. - It supports enough richness (
enum,pattern, nestedobject/array,minimum/maximum) to catch a large class of malformed calls structurally, before your handler code runs. - It's self-describing. Per-field
descriptionstrings give the model extra guidance beyond the top-level tool description, like the expected format of a date string or ID.
Server-side validation is still your job
MCP doesn't validate arguments for you at the protocol level. That's the server's responsibility:
from jsonschema import validate, ValidationError
def call_tool(name, arguments):
schema = TOOLS[name]["inputSchema"]
try:
validate(instance=arguments, schema=schema)
except ValidationError as e:
# Skipping this check means a model hallucinating a wrong field name
# or type reaches business logic directly — a common source of
# confusing runtime errors, and in the worst case a security gap.
return {"isError": True, "content": [{"type": "text", "text": f"Invalid arguments: {e.message}"}]}
return execute(name, arguments)
Related Resources
Tracing one full round trip end to end makes the pieces click together.
Step by step
// 1. Client -> Server: discover tools
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
// Server -> Client
{"jsonrpc": "2.0", "id": 1, "result": {
"tools": [
{"name": "get_weather", "description": "...", "inputSchema": {...}}
]
}}
// 2-3. Host sends tools + conversation to the LLM's own API.
// The model returns something like: "call get_weather(city='Austin')"
// 4. Host -> Server: invoke it
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {
"name": "get_weather", "arguments": {"city": "Austin"}
}}
// 5. Server -> Host
{"jsonrpc": "2.0", "id": 2, "result": {
"content": [{"type": "text", "text": "Austin: 91F, sunny"}],
"isError": false
}}
// 6. Host appends "Austin: 91F, sunny" as the tool result in the conversation,
// and the model produces its final natural-language answer using it.
Where the host does real work
Steps 2-3 and 6 happen entirely inside the host/client, not over MCP at all. MCP only governs steps 1, 4, and 5. This is why the same MCP server works unmodified whether the host uses Anthropic's tool-use format, OpenAI's function-calling format, or something else. Reshaping into a model-specific format is the host's job, not the server's.
Where things typically go wrong
- Forgetting that
tools/listmay be paginated (anextCursorin the result), and only fetching the first page. - Not handling
isError: truedistinctly from a JSON-RPC-level error. A tool that fails at the business-logic level (e.g. "city not found") should return a normal result withisError: true, not a protocol error.
Related Resources
Annotations give a host enough signal to build sensible default UX around risky actions. But they only work if you remember who's making the claim.
The four standard annotations
{
"name": "delete_file",
"description": "Permanently delete a file from disk.",
"inputSchema": { "type": "object", "properties": { "path": {"type": "string"} }, "required": ["path"] },
"annotations": {
"title": "Delete File",
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
}
| Annotation | Meaning |
|---|---|
readOnlyHint | Tool does not modify its environment |
destructiveHint | Tool may perform irreversible or destructive updates (only meaningful when readOnlyHint is false) |
idempotentHint | Calling it again with identical arguments has no additional effect beyond the first call |
openWorldHint | Tool interacts with an open-ended external domain (e.g., web search) rather than a fixed, closed set of entities |
How a host is expected to use them
A reasonable host policy: auto-approve calls to tools annotated readOnlyHint: true without prompting. Require explicit user confirmation for anything with destructiveHint: true. Treat idempotentHint: false tools as unsafe to silently retry after a timeout, since a retry could duplicate a real-world effect like sending an email twice.
Why "hint," not "contract"
The annotation is written by the server author. The server is exactly the thing a host may not fully trust — it could be a third-party plugin the user just installed. A server has no obligation to be honest. It could label delete_all_data as readOnlyHint: true to slip past a host's confirmation UI. This is precisely why the spec calls them hints. Treating them as an enforceable security boundary would create a false sense of safety. A host that actually cares about safety still needs independent trust signals: provenance of the server, sandboxing, and user awareness of what a newly installed server can do. Annotations only optimize the common case where the server behaves honestly.
Related Resources
Mixing these two error channels up is one of the most common mistakes in a first MCP server implementation.
Two different failure shapes
// Protocol-level error: the REQUEST was invalid (unknown tool, bad JSON-RPC)
{"jsonrpc": "2.0", "id": 5, "error": {
"code": -32602, "message": "Unknown tool: send_emial"
}}
// Execution error: the request was valid, the TOOL failed while running
{"jsonrpc": "2.0", "id": 5, "result": {
"isError": true,
"content": [{"type": "text", "text": "SMTP server rejected the message: mailbox not found"}]
}}
Why the split matters
- A JSON-RPC
errorsignals a bug in how the client is talking to the server — wrong method, malformed arguments per the schema, tool doesn't exist. It's a plumbing failure. The model generally can't meaningfully react to it. isError: trueinside a normal result means the request was well-formed, but something in the real world went wrong. This content still flows back into the model's context like any other tool result. The model can read "mailbox not found," recognize the problem, and retry with corrected arguments, try a different tool, or tell the user what happened.
A concrete implementation pattern
@mcp.tool()
def send_email(to: str, subject: str, body: str) -> str:
try:
smtp_client.send(to, subject, body)
return f"Email sent to {to}"
except SMTPRecipientRefused:
# Wrapped as isError: true, not a JSON-RPC error — the model sees it
# in context and can retry, adjust arguments, or explain it to the user,
# instead of the interaction breaking on an opaque protocol failure.
raise ToolExecutionError(f"Recipient {to} was refused by the mail server")