Designing an MCP tool: the definition and the result

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. 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 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:

Host + client → Server: tools/list; Server → Host + client: tools (deterministic order) + ttlMs + cacheScope; Host + client → LLM: tool names / schemas (possibly deferred via tool search); LLM → Host + client: select get_weather_data, location Toronto; Note (Host + client): optional user confirmation; Host + client → Server: tools/call get_weather_data; Server → Host + client: content + structuredContent (+ isError); Host + client → LLM: tool result; opt: list changes; Server → Host + client: notifications/tools/ list_changed (via subscriptions/listen); Host + client → Server: tools/listServerHost + clientLLMoptional userconfirmationopt[list changes]tools/listtools (deterministic order)+ ttlMs + cacheScopetool names / schemas(possibly deferredvia tool search)select get_weather_data,location Torontotools/callget_weather_datacontent+ structuredContent(+ isError)tool resultnotifications/tools/list_changed(via subscriptions/listen)tools/list

Servers SHOULD send the list in a deterministic order; it carries caching hints, and subscribed clients hear when it changes (part 6). The host gives the model the names and schemas, or only the names if it uses tool search (part 4).

Anatomy of a tool definition

Here is my guide’s weather tool, rewritten in Python with the official SDK, mcp 2.3.0:

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:

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:

FieldNotes
nameUnique within the server; see the naming rules below
titleOptional display name for user interfaces
descriptionWhat the model reads to decide whether and how to call the tool
iconsOptional icons for host user interfaces
inputSchemaMUST be a valid JSON Schema object, never null. Without a $schema, it’s JSON Schema 2020-12, and any 2020-12 keyword is allowed
outputSchemaOptional. If present, structured results MUST conform to it
annotationsOptional hints about the tool’s behaviour
_metaOptional 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 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

AnnotationMeaning
readOnlyHintThe tool doesn’t modify its environment
destructiveHintUpdates may be destructive, as opposed to only additive
idempotentHintRepeated calls with the same arguments have no additional effect
openWorldHintThe 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:

TypeUse it for
textThe default: anything the model should read
imageBase64 data plus its Multipurpose Internet Mail Extensions (MIME) type, such as charts or screenshots
audioBase64 data plus its MIME type
resource_linkA 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, the extension for interactive user interfaces in the conversation, keeps structuredContent out of the model’s context by convention. Anthropic’s Agent SDK documentation 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.

tool result → content the readable half; tool result → structuredContent the data half; content the readable half → text-only hosts; content the readable half → hosts that follow the MCP Apps convention; structuredContent the data half → MCP Apps views; structuredContent the data half → client-side code; structuredContent the data half → downstream automationtool resultcontentthe readable halfstructuredContentthe data halftext-only hostshosts that followthe MCP Apps conventionMCP Apps viewsclient-side codedownstream 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”.
Series What an MCP server actually does in 2026 Part 8 of 35
  1. What an MCP server does in 2026: much more than a list of tools
  2. MCP goes stateless: what changed in the 2026-07-28 specification
  3. server/discover: the one method every MCP server must implement
  4. Server instructions: the paragraph every model reads first
  5. Stateless MCP requests: _meta, resultType and explicit handles
  6. Caching hints and pagination in MCP
  7. MCP transports in 2026: stdio and Streamable HTTP
  8. Designing an MCP tool: the definition and the result
  9. MCP tool errors, and tools that models use well
  10. MCP resources: context the application decides to use
  11. MCP prompts: slash commands your server ships
  12. Tool, resource or prompt? Choosing the right MCP primitive

I’m Amir Pournasserian. I build AI and platform systems for a living, maintain FluentCMS and YeSvelte, and write here about what I find along the way.