An MCP tool is a function that an MCP server exposes for a language model to call. The server describes each tool with a name, a plain-language description and a JSON Schema for its arguments. The client fetches that list with tools/list and hands it to the model, and the model decides when to call a tool with tools/call. The spec calls tools model-controlled. That is the difference from prompts, which the user picks, and resources, which the host loads (tools vs prompts vs resources).
Getting a tool to run is easy. The hard part is getting one the model picks at the right moment, calls with valid arguments, and recovers from when something goes wrong. That comes down to five parts of the definition: the name and description, the input schema, the annotations, the error channel, and the output schema. This guide covers each one against the current spec revision, 2026-07-28. Every code sample was run on October 21, 2026 with the official Python SDK, mcp 2.3.0, and the outputs shown are the real ones.
Our own server, DialMCP, places real phone calls through four tools, so we use those as a worked example, including the places where they don't follow the advice below.
What's in a tool definition
This is everything a server can put in one entry of a tools/list response:
| Field | Required | What it's for |
|---|---|---|
name | Yes | The identifier the model calls. Unique within the server. |
title | No | A human-readable display name for UIs. |
description | No (in practice, yes) | What the tool does and when to use it. This is what the model reads when it chooses a tool. |
inputSchema | Yes | JSON Schema for the arguments. The root must be type: "object". |
outputSchema | No | JSON Schema for structuredContent in the result. |
annotations | No | Behavior hints: read-only, destructive, idempotent, open-world. |
icons | No | Images a client can show next to the tool. |
_meta | No | Extension data, such as host-specific UI hints. |
The name rules are SHOULDs, not MUSTs, but follow them anyway. Names should be 1 to 128 characters long and case-sensitive, using only ASCII letters, digits, underscore, hyphen and dot. That means no spaces or commas. getUser, DATA_EXPORT_v2 and admin.tools.list are all valid. Uniqueness only holds within one server. If two servers each expose search, the client has to tell them apart. The spec suggests prefixing a server identifier, and warns that the server's self-reported serverInfo name isn't guaranteed to be unique. Hosts can be stricter than the spec: Anthropic's connector directory rejects tool names longer than 64 characters.
For display, a client uses title first, then annotations.title, then name. The model works from the name and the description, and the description does most of the work. OpenAI's Apps SDK guidance suggests starting it with "Use this when…" and naming the cases where the tool should not be used. Keep it about the tool. Pointing the model at the tool's own output is common: DialMCP's place_call description asks the model to offer the user the live-listen link. Anthropic's directory review, though, rejects descriptions that reach beyond the tool, such as telling Claude to call software the user didn't ask for or to get in the way of other tools. It treats those as prompt injection.
The input schema: where most bad calls are prevented
inputSchema is ordinary JSON Schema, 2020-12 by default unless you set $schema. The 2026-07-28 schema states outright that the root must be type: "object", because arguments always arrive as a JSON object, and that any other 2020-12 keyword may sit alongside it, including composition (oneOf, anyOf), conditionals and $ref/$defs. For a tool that takes nothing, the spec recommends a schema that accepts only an empty object:
{"type": "object", "additionalProperties": false}
Every constraint you put in the schema is one the model can see before it calls. SDKs that build the schema from your types, like the Python and TypeScript ones, also enforce it when the call arrives. Use them:
- Describe every property. The property description is often the only place a format gets explained.
- Use
enumfor closed sets rather than a free string plus a sentence listing the options. - Bound numbers and strings. Set
minimum/maximum,minLength/maxLengthandpattern. - Prefer flat arguments. Two string fields are easier for a model to fill correctly than a nested object with optional branches.
DialMCP's place_call shows the trade-offs. objective is a string of 10 to 2,000 characters, and max_duration_minutes is an integer from 1 to 10. Both bounds sit in the schema, so the SDK rejects a 30-minute call before our code runs. The destination number to is a plain string, though. The E.164 rule lives in the description, in the docs (+1 and ten digits) and in a server-side check that returns invalid_number. A pattern would let the SDK catch it one step earlier. The full contract is in the DialMCP tool reference.
A tested example in Python
Here is a small order-lookup server with two tools: one that only reads, and one that changes state. It uses the official Python SDK's MCPServer class (which SDK to pick):
from typing import Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("orders")
ORDERS = {"A-1001": "shipped", "A-1002": "processing"}
class Order(BaseModel):
order_id: str
status: Literal["processing", "shipped", "canceled"]
@mcp.tool(
title="Look up an order",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
)
def get_order(
order_id: str = Field(description="Order ID, e.g. A-1001", pattern=r"^A-\d{4}$"),
) -> Order:
"""Use this when the user asks where an order is. Returns its current status."""
if order_id not in ORDERS:
raise ToolError(f"No order {order_id}. Ask the user to check the ID on their receipt.")
return Order(order_id=order_id, status=ORDERS[order_id])
@mcp.tool(
title="Cancel an order",
annotations=ToolAnnotations(
readOnlyHint=False, destructiveHint=True, idempotentHint=True, openWorldHint=False
),
)
def cancel_order(order_id: str = Field(pattern=r"^A-\d{4}$")) -> Order:
"""Cancel an order that has not shipped yet. Cancelling twice is a no-op."""
if ORDERS.get(order_id) == "shipped":
raise ToolError(f"{order_id} has already shipped and can't be canceled. Offer a return instead.")
ORDERS[order_id] = "canceled"
return Order(order_id=order_id, status="canceled")
The SDK builds the whole tool definition from the type hints and decorator arguments. This is what tools/list returned for get_order, with the outputSchema's property titles trimmed. In the results further down we also drop the _meta block the SDK adds (it only names the server):
{
"name": "get_order",
"title": "Look up an order",
"description": "Use this when the user asks where an order is. Returns its current status.",
"inputSchema": {
"type": "object",
"properties": {
"order_id": {
"description": "Order ID, e.g. A-1001",
"pattern": "^A-\\d{4}$",
"title": "Order Id",
"type": "string"
}
},
"required": ["order_id"],
"title": "get_orderArguments"
},
"outputSchema": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"status": {"enum": ["processing", "shipped", "canceled"], "type": "string"}
},
"required": ["order_id", "status"],
"title": "Order"
},
"annotations": {"readOnlyHint": true, "openWorldHint": false}
}
The docstring became the description, the Field became a described, pattern-checked property, and the Order return type became an outputSchema. You can watch the same thing in the MCP Inspector, and the Python SDK tutorial covers the surrounding server setup.
Tool annotations: what the four hints mean
Annotations tell the client how a tool behaves, so it can decide whether to ask the user first. There are four hints. The defaults matter more than people expect:
| Hint | True means | Default if unset |
|---|---|---|
readOnlyHint | The tool doesn't modify its environment. | false |
destructiveHint | The tool may make destructive updates. False means it only adds. | true |
idempotentHint | Repeating the call with the same arguments has no further effect. | false |
openWorldHint | The tool reaches external entities, like a web search. False means a closed domain, like a memory store. | true |
destructiveHint and idempotentHint only mean something when readOnlyHint is false. Put the defaults together and a tool with no annotations reads as a non-idempotent, destructive write that reaches the outside world. In other words, a client that knows nothing assumes the worst.
Hosts act on these hints. Anthropic's connector pre-submission checklist requires every tool to have a title and the applicable hint, and says the hints "determine auto-permissions in Claude": read-only tools can run without a per-call confirmation, and destructive tools always prompt. OpenAI's plugin reference lists readOnlyHint, destructiveHint and openWorldHint as required, and says they shape how ChatGPT or Codex frames a tool call to the user. It also notes that a private account isn't open-world just because it is hosted elsewhere. Both also want reads and writes in separate tools (OpenAI's tool-planning guide says so explicitly). Claude's directory rejects a catch-all api_request tool that takes a method parameter.
Two cautions. First, an annotation is a claim, not a control. The spec says clients MUST treat annotations as untrusted unless they come from a trusted server, so a malicious server can mark a delete as read-only. Your server still has to enforce its own limits. Second, be honest when you mark a tool, because an overclaimed readOnlyHint removes the one confirmation a user would have seen.
Errors: protocol errors vs tool execution errors
MCP has two error channels, and they reach different readers:
| Protocol error | Tool execution error | |
|---|---|---|
| Shape | JSON-RPC error object, e.g. code -32602 | Normal result with isError: true |
| For | Unknown tool, malformed request, server failure | API failures, invalid input values, business-rule refusals |
| Does the model see it? | Clients MAY pass it on | Clients SHOULD pass it on, so the model can self-correct |
The rule is simple: if the model could fix the problem by calling differently, return a tool result with isError: true and a message that says what to do next. "No order A-9999. Ask the user to check the ID on their receipt." is useful. "Error 404" is not.
In the Python SDK, raising ToolError does this for you. Calling get_order with an unknown ID returned this:
{
"content": [{"type": "text", "text": "Error executing tool get_order: No order A-9999. Ask the user to check the ID on their receipt."}],
"isError": true,
"resultType": "complete"
}
Schema violations take the same path. Calling get_order with "1001" produced an isError result whose text quoted pydantic's "String should match pattern" message, so the model sees exactly which argument was wrong.
One gap between the spec and the SDK: the spec lists an unknown tool as a protocol error, but mcp 2.3.0 returned a call to a nonexistent refund_order as an isError result with the text "Unknown tool: refund_order", not a JSON-RPC error. If you write a client, handle both. If you write a server, check what your SDK actually sends before documenting it.
DialMCP takes its own route here, and its docs say so. Refusals such as a call outside the 8am to 9pm window, a rate limit, or a malformed number come back as an ordinary result with an error code and a message, not with isError set. The model still reads the reason and can relay it. The spec's guidance, though, is to flag business-logic errors with isError: true, which is why our tool reference tells client authors to check for the error key as well. If you are starting fresh, set the flag.
Structured output: structuredContent and outputSchema
A tool result always has a content array of blocks: text, image, audio, a resource link, or an embedded resource. Since the 2025-06-18 revision it can also carry structuredContent, a JSON value for code to consume. On 2026-07-28 that value can be any JSON type, including an array or a bare string. If the tool declares an outputSchema, the server MUST return structured results that conform, and clients SHOULD validate them. For older clients, a tool that returns structured content SHOULD also put the serialized JSON in a text block.
The Python example did that with no extra code. A successful get_order returned both:
{
"content": [{"type": "text", "text": "{\n \"order_id\": \"A-1001\",\n \"status\": \"shipped\"\n}"}],
"structuredContent": {"order_id": "A-1001", "status": "shipped"},
"isError": false,
"resultType": "complete"
}
Two things trip people up. First, structuredContent is not the same as an LLM's "structured outputs" feature. It is data the server produced, and nothing constrains how the model generates text. Second, the SDK only infers an output schema from a type it can describe. In our October 14 tests, a Python tool annotated -> dict returned no structured content at all, while a pydantic model did. An error result like the ones above carries no structuredContent either, so don't validate it against the schema.
When is structured output worth it? Whenever something other than the model reads the result: a UI widget, a client-side workflow, a test. DialMCP's tools return pretty-printed JSON in a single text block and declare no outputSchema. Models read that fine. A client that wants to branch on resolution.outcome has to parse the text, though.
Long-running tools and handles
A tools/call is one request and one response, and clients put timeouts on it. Codex, for example, has a per-server tool_timeout_sec. A phone call takes minutes, so place_call can't wait for the hang-up. It returns a call_id straight away, and the agent follows up with get_call. get_call takes an optional wait_seconds (0 to 50) and holds the request until the status changes or the wait runs out, so the agent makes a few long polls instead of many short ones.
That is the pattern the spec's non-normative "Stateful Tools" section describes. MCP has no protocol-level session, so state lives behind an explicit handle that the model carries from one call to the next. The spec's advice for handles is worth following. Authorize the caller against the handle on every call. Keep handles opaque. State their lifetime in the description, and return a clear tool error for an expired handle so the model can start over. DialMCP's IDs belong to the account that created them. An unknown or foreign ID returns not_found, and list_calls recovers an ID the model lost. There is more on why MCP has no implicit session in our Streamable HTTP explainer.
DialMCP's tools, annotated
As of October 2026, DialMCP's four tool definitions set no title and no annotations. Under the defaults, a client treats all four as destructive, open-world writes. This is how they classify on the hints' own definitions:
| Tool | readOnly | destructive | idempotent | openWorld | Why |
|---|---|---|---|---|---|
place_call | false | true | false | true | Dials a real person. A call can't be unsent, and calling twice places two calls. |
end_call | false | true | true | true | Hangs up a live call. Ending an already-ended call changes nothing (strictly, a second request during the 10-second wrap-up hangs up at once and records the call as canceled). |
get_call | true | n/a | n/a | false | Reads one call record in the user's account. |
list_calls | true | n/a | n/a | false | Reads call history. |
For place_call the defaults already match, though both directories want the title and hints written out. It is exactly the kind of tool a user should confirm, and the hints should never be used to skip that. The gap is the two read tools. Without readOnlyHint, a host that follows the hints may ask for permission on every status check while a call is running. That is why the annotations belong on even a four-tool server. Our safety rules don't depend on them either way. Disclosure, the calling window, rate limits and the opt-out list are enforced on the server for every call (how DialMCP enforces them).
A checklist for every tool you ship
- Give it a short, action-shaped name (64 characters or fewer if you want it listed with Anthropic) and a
title. - Write a description that says when to use it and when not to. Don't use it to steer the model toward other tools or software.
- Describe every argument, and put formats in
pattern,enumand range keywords, not just in prose. - Split reads from writes. Set
readOnlyHinthonestly. On writes, setdestructiveHint,idempotentHintandopenWorldHintexplicitly. - Return recoverable failures as
isError: trueresults with a next step in the text. - Declare an
outputSchemawhen code will read the result, and still include the text block. - For anything slower than your clients' timeouts, return a handle and give the model a way to check on it.
- Enforce limits on the server. Annotations inform the client. They protect nothing.
If you are still deciding whether MCP is the right layer at all, start with MCP vs API. To build the whole server from scratch, see how to build an MCP server.
Want to see these tools from the model's side? DialMCP is a hosted remote MCP server whose four tools let Claude, ChatGPT, Codex or Cursor place a real phone call from your own verified number and read back the transcript and outcome. Connect to https://mcp.dialmcp.com/mcp and sign in with OAuth. US and Canada, free during launch.
FAQ
What are MCP tools?
MCP tools are functions an MCP server exposes for a language model to call. Each tool has a name, a description and a JSON Schema for its arguments. The client lists them with tools/list, the model decides when to use one, and the client runs it with tools/call. Tools are model-controlled, unlike prompts (picked by the user) and resources (loaded by the host).
What are MCP tool annotations?
Four optional hints about how a tool behaves: readOnlyHint (default false), destructiveHint (default true), idempotentHint (default false) and openWorldHint (default true). Hosts use them to decide when to ask the user for confirmation. A tool with no annotations is treated as a destructive write that reaches the outside world.
Are tool annotations a security control?
No. The MCP spec says clients must treat annotations as untrusted unless they come from a trusted server, because a server can mislabel a tool. They help a host decide what to show the user. The server still has to enforce its own access checks, rate limits and other rules.
What is the difference between isError and a JSON-RPC error in MCP?
A JSON-RPC error is a protocol error: an unknown tool, a malformed request or a server failure. A result with isError: true is a tool execution error, such as an invalid value, a failed API call or a refused action. Clients should show tool execution errors to the model so it can correct the call and try again.
Do MCP tools need an outputSchema?
No, it is optional. Declare one when code, a UI or a test will read the result. The server then must return structuredContent that matches it, and should also include the same JSON as a text block for older clients. If only the model reads the result, plain text is enough.