Designing an MCP tool: the definition and the result The Model Context Protocol's 2026-07-28 specification requires that a human always be able to deny a tool call, that `tools/list` return only the tools available to the requesting client, and that tool annotations be treated as untrusted hints, according to a guide to designing MCP tools. The guide, part 8 of a series on MCP servers, shows how the official Python SDK version 2.3.0 builds a tool definition from a decorator, docstring and type hints, and notes a result carries `content`, `structuredContent` or both, with structured content accompanied by a text rendering. The author states the facts are as read in October 2026. Designing an MCP tool: the definition and the result A Model Context Protocol MCP tool definition looks small: a name, a description and a schema. What I find easy to miss is how many readers those lines have. The model reads the description to decide whether to call the tool, the host reads the annotations to decide whether to ask the user first, and a program may read the structured result. This is part 8 of my series on what an MCP server does under the 2026-07-28 specification, after the transports in part 7 https://pournasserian.com/writing/mcp-2026-transports . The facts are as I read them in October 2026. In brief 1. A tool is a named function with a JSON Schema input that the model chooses to call. A human SHOULD always be able to deny the call. 2. tools/list returns the tools available to the requesting client. The set MAY vary with authorization and MUST NOT vary per connection. 3. Annotations are hints, not guarantees. They’re optional in the specification, the Claude directory and ChatGPT each require some of them, and clients MUST treat them as untrusted unless the server is trusted. 4. A result carries content , structuredContent or both, and structured content SHOULD come with a text rendering. 5. The official Python software development kit SDK builds the definition from your code: the decorator gives the name, title and annotations, the docstring the description, and the type hints both schemas. Listing tools, with a human in the loop The tools page of the specification https://modelcontextprotocol.io/specification/2026-07-28/server/tools opens with a warning: there SHOULD always be a human in the loop who can deny tool invocations. Applications SHOULD show which tools are exposed and when they’re invoked, and ask for confirmation on sensitive operations. Hosts often prompt before calling a tool that looks destructive, so describe your tools honestly. A server with tools MUST declare the tools capability, optionally with listChanged: true , and answer tools/list with the tools currently available to the requesting client. That set can be empty or change, and MAY vary with the request’s authorization: you can return only the tools the caller’s scopes permit. It MUST NOT vary per connection or as a side effect of other requests. A tool’s life, from the list to the large language model LLM and back: Servers SHOULD send the list in a deterministic order; it carries caching hints, and subscribed clients hear when it changes part 6 https://pournasserian.com/writing/mcp-2026-caching-and-pagination . The host gives the model the names and schemas, or only the names if it uses tool search part 4 https://pournasserian.com/writing/mcp-2026-server-instructions . Anatomy of a tool definition Here is my guide’s weather tool, rewritten in Python with the official SDK https://py.sdk.modelcontextprotocol.io/servers/tools/ , mcp 2.3.0: python from typing import Annotated from mcp.server import MCPServer from mcp.types import CallToolResult, TextContent, ToolAnnotations from pydantic import BaseModel, Field mcp = MCPServer "weather" class WeatherReading BaseModel : temperature: float = Field description="Celsius" conditions: str humidity: float @mcp.tool name="get weather data", title="Weather Data Retriever", annotations=ToolAnnotations read only hint=True, destructive hint=False, open world hint=True , def get weather location: Annotated str, Field description="City name or postal code" , - Annotated CallToolResult, WeatherReading : """Get current weather for a city. Returns Celsius temperature, conditions, and humidity.""" Stand-in for a real weather API: the same values for every location. w = WeatherReading temperature=18.5, conditions="partly cloudy", humidity=62 text = f"{location}: {w.temperature} °C, {w.conditions}, humidity {w.humidity:.0f}%" return CallToolResult content= TextContent text=text , structured content=w.model dump Because the tool builds its own CallToolResult , to send a sentence as the text, the return annotation Annotated CallToolResult, WeatherReading tells the SDK that WeatherReading is the output schema. The SDK’s in-memory Client lists and calls the tool: python import json import anyio from mcp import Client async def main : async with Client mcp as client: listed = await client.list tools tool = listed.tools 0 .model dump mode="json", by alias=True, exclude none=True print json.dumps tool, indent=2, ensure ascii=False result = await client.call tool "get weather data", {"location": "Toronto"} print "content:", result.content 0 .text print "structuredContent:", result.structured content if name == " main ": anyio.run main The listing printed: { "name": "get weather data", "title": "Weather Data Retriever", "description": "Get current weather for a city. Returns Celsius temperature, conditions, and humidity.", "inputSchema": { "type": "object", "properties": { "location": { "description": "City name or postal code", "title": "Location", "type": "string" } }, "required": "location" , "title": "get weatherArguments" }, "outputSchema": { "properties": { "temperature": { "description": "Celsius", "title": "Temperature", "type": "number" }, "conditions": { "title": "Conditions", "type": "string" }, "humidity": { "title": "Humidity", "type": "number" } }, "required": "temperature", "conditions", "humidity" , "title": "WeatherReading", "type": "object" }, "annotations": { "readOnlyHint": true, "destructiveHint": false, "openWorldHint": true } } The schemas’ title keys come from Pydantic and the function name; they aren’t the tool’s title. The specification’s fields: | Field | Notes | |---|---| | name | Unique within the server; see the naming rules below | | title | Optional display name for user interfaces | | description | What the model reads to decide whether and how to call the tool | | icons | Optional icons for host user interfaces | | inputSchema | MUST be a valid JSON Schema object, never null . Without a $schema , it’s JSON Schema 2020-12, and any 2020-12 keyword is allowed | | outputSchema | Optional. If present, structured results MUST conform to it | | annotations | Optional hints about the tool’s behaviour | | meta | Optional extension and vendor metadata, such as meta.ui.resourceUri for MCP Apps | For a tool with no parameters, the recommended input schema is { "type": "object", "additionalProperties": false } , which accepts only an empty object. Naming rules - 1 to 128 characters, case-sensitive. - Only the letters A to Z and a to z, digits, underscore, hyphen and dot. - Unique within a server. A host that aggregates servers SHOULD disambiguate collisions two servers that both expose search , for example with a server prefix. The server name in serverInfo isn’t guaranteed to be unique and SHOULD NOT be relied on for that. My post on what AI hosts require of an MCP server https://pournasserian.com/writing/what-ai-hosts-require-of-an-mcp-server records stricter host rules: the Claude directory and Kiro allow 64 characters, with Kiro counting the server prefix; Gemini CLI truncates names over 63; and Kiro rejects hyphens and dots. A short name of letters, digits and underscores that starts with a letter, like get weather data , passes them all. Annotations are hints, not guarantees | Annotation | Meaning | |---|---| | readOnlyHint | The tool doesn’t modify its environment | | destructiveHint | Updates may be destructive, as opposed to only additive | | idempotentHint | Repeated calls with the same arguments have no additional effect | | openWorldHint | The tool interacts with an open world of external entities, such as the web | Clients MUST treat annotations as untrusted unless they come from trusted servers: a malicious server can label a destructive tool read-only, so a host should never auto-approve on a hint alone. Some hosts require them anyway. The same post records that, as of 1 October 2026, the Claude directory needs a title plus readOnlyHint or destructiveHint on every tool, and ChatGPT needs readOnlyHint , destructiveHint and openWorldHint . So the weather tool sets a title and all three. What a tool returns A result may carry a content array, structuredContent , or both. The array’s blocks come in five types: | Type | Use it for | |---|---| | text | The default: anything the model should read | | image | Base64 data plus its Multipurpose Internet Mail Extensions MIME type, such as charts or screenshots | | audio | Base64 data plus its MIME type | | resource link | A Uniform Resource Identifier URI the client can fetch or subscribe to. These links aren’t guaranteed to appear in resources/list | | resource embedded | Inline resource contents with a URI and MIME type. Servers that embed resources SHOULD implement the resources capability | Content blocks have annotations of their own, separate from the tool’s: audience user or assistant , priority from 0 to 1, and lastModified . Use them to mark, say, an image for the user and a summary for the model. Structured content structuredContent holds any JSON value that conforms to the tool’s outputSchema , if one is defined, and clients SHOULD validate it. It has nothing to do with a model’s “structured outputs” feature; it’s data your server produced. For backward compatibility, a tool that returns it SHOULD also return the serialized JSON, or a readable rendering of it, as a text block. The weather tool sends a sentence: content: Toronto: 18.5 °C, partly cloudy, humidity 62% structuredContent: {'temperature': 18.5, 'conditions': 'partly cloudy', 'humidity': 62.0} The SDK checks structured content against WeatherReading before sending; a mismatch reaches the client as an error result isError: true , not the data. Return a WeatherReading itself and the SDK fills in both halves, with the JSON as the text block. Two audiences Which half the model sees depends on the host. MCP Apps https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx , the extension for interactive user interfaces in the conversation, keeps structuredContent out of the model’s context by convention. Anthropic’s Agent SDK documentation https://code.claude.com/docs/en/agent-sdk/custom-tools says the opposite for the Claude Code runtime: Claude receives the structuredContent JSON plus any image or resource blocks, not the text blocks, so it would get the weather JSON, not the sentence. ChatGPT also lets the model see structuredContent . The specification only asks for a text rendering. My advice goes further: write for two audiences and make each half stand on its own. content is the readable half, concise with the important facts first, for text-only hosts and hosts that follow the MCP Apps convention. structuredContent is the data half, for programs such as MCP Apps views, client-side code and downstream automation. Method and caveats - Built from the first half of my guide’s tools chapter, against the 2026-07-28 specification; product facts as of October 2026, host rules as of 1 October 2026. - The Python sample ran against mcp 2.3.0 on 9 October 2026 and printed the output shown; a second run tested the output check and a returned model. - Corrected from my notes: which half of a result the model sees. I follow the MCP Apps specification and the Anthropic Agent SDK and OpenAI Apps SDK documentation, checked on 8 October 2026; the diagram’s last step says only “tool result”.