cd /news/agent-protocols/designing-an-mcp-tool-the-definition… · home › topics › agent-protocols › article
[ARTICLE · art-150675] src=pournasserian.com ↗ pub= topic=agent-protocols verified=true sentiment=· neutral

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.

by read8 min views2 publishedOct 15, 2026
Designing an MCP tool: the definition and the result
Image: Pournasserian (auto-discovered)

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:

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

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 servername inserverInfo 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

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

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”.
── more in #agent-protocols 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/designing-an-mcp-too…] indexed:0 read:8min 2026-10-15 · —