# Designing an MCP tool: the definition and the result

October 15, 2026 · Part 8 of 35

![Designing an MCP tool: the definition and the result](https://pournasserian.com/_astro/lead.DnoqjY-x_28SEnY.webp)

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:

```mermaid
sequenceDiagram
    participant L as LLM
    participant H as Host + client
    participant S as Server

    H->>S: tools/list
    S-->>H: tools (deterministic order)<br/>+ ttlMs + cacheScope
    H->>L: tool names / schemas<br/>(possibly deferred<br/>via tool search)
    L->>H: select get_weather_data,<br/>location Toronto
    Note over H: optional user<br/>confirmation
    H->>S: tools/call<br/>get_weather_data
    S-->>H: content<br/>+ structuredContent<br/>(+ isError)
    H->>L: tool result
    opt list changes
        S-->>H: notifications/tools/<br/>list_changed<br/>(via subscriptions/listen)
        H->>S: tools/list
    end
```

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:

```json
{
  "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:

```text
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.

```mermaid
flowchart LR
    r["tool result"]
    c["content<br/>the readable half"]
    s["structuredContent<br/>the data half"]
    t["text-only hosts"]
    m["hosts that follow<br/>the MCP Apps convention"]
    v["MCP Apps views"]
    k["client-side code"]
    d["downstream automation"]
    r --> c
    r --> s
    c --> t
    c --> m
    s --> v
    s --> k
    s --> d
```

## 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".

I'm [Amir Pournasserian](https://pournasserian.com/about). I build AI and platform systems for a living, maintain [FluentCMS](https://pournasserian.com/open-source/fluentcms) and [YeSvelte](https://pournasserian.com/open-source/yesvelte), and write here about what I find along the way.
