From e7ea67c96a83424ab673986eb49f507b473df823 Mon Sep 17 00:00:00 2001 From: a-akimov Date: Sun, 1 Nov 2026 22:42:44 +0100 Subject: [PATCH] Add tool annotations to both tools in every weather server Both tools only read data from an outside service (the NWS API), so every server now declares readOnlyHint: true and openWorldHint: true on get_alerts and get_forecast. destructiveHint: false and idempotentHint: true are set explicitly as well. The Go SDK always sends idempotentHint, and the Ruby gem always sends all four hints with defaults meant for tools that write (destructive, not idempotent). Setting all four keeps the five servers' tools/list output identical. The test client now fails a server whose tools don't carry these four annotations. Each server README gets a short section on what the hints mean to a host. Closes #271 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NkL34oWhufSSqj9FbPx5dr --- tests/helpers/mcp-test-client.ts | 25 +++++++++++++++++++++++++ weather-server-go/README.md | 4 ++++ weather-server-go/main.go | 15 +++++++++++++++ weather-server-python/README.md | 4 ++++ weather-server-python/weather.py | 15 +++++++++++++-- weather-server-ruby/README.md | 4 ++++ weather-server-ruby/weather.rb | 13 +++++++++++++ weather-server-rust/README.md | 4 ++++ weather-server-rust/src/main.rs | 15 +++++++++++++++ weather-server-typescript/README.md | 4 ++++ weather-server-typescript/src/index.ts | 12 ++++++++++++ 11 files changed, 113 insertions(+), 2 deletions(-) diff --git a/tests/helpers/mcp-test-client.ts b/tests/helpers/mcp-test-client.ts index aeede7fa..c7a670fd 100644 --- a/tests/helpers/mcp-test-client.ts +++ b/tests/helpers/mcp-test-client.ts @@ -5,6 +5,7 @@ * Connects to a server, initializes, lists tools, and then checks the tools * actually honour what they advertise: * + * - every tool declares the same annotations (read-only, open-world); * - every tool that declares an `outputSchema` is called, and the result must * carry `structuredContent` (the SDK validates it against the schema for us * and throws on a mismatch); @@ -25,6 +26,18 @@ const TOOL_ARGUMENTS: { match: RegExp; args: Record }[] = [ { match: /forecast/i, args: { latitude: 38.5816, longitude: -121.4944 } }, ]; +/** + * The annotations every weather tool must declare, identical across the five + * servers. All four are set explicitly because the Go and Ruby SDKs always send + * some hints, so leaving them to defaults would make the servers disagree. + */ +const EXPECTED_ANNOTATIONS = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +} as const; + function argumentsFor(name: string): Record | undefined { return TOOL_ARGUMENTS.find(({ match }) => match.test(name))?.args; } @@ -55,6 +68,18 @@ async function testServer(command: string, args: string[]) { const { tools } = await client.listTools(); console.error(`✓ Listed ${tools.length} tools`); + for (const tool of tools) { + for (const [hint, value] of Object.entries(EXPECTED_ANNOTATIONS)) { + const actual = tool.annotations?.[hint as keyof typeof EXPECTED_ANNOTATIONS]; + if (actual !== value) { + throw new Error( + `${tool.name} should set annotations.${hint} to ${value}, got ${actual}`, + ); + } + } + } + console.error(`✓ All ${tools.length} tools carry the expected annotations`); + let checked = 0; for (const tool of tools) { if (!tool.outputSchema) continue; diff --git a/weather-server-go/README.md b/weather-server-go/README.md index 801f4da0..bc2f8c3b 100644 --- a/weather-server-go/README.md +++ b/weather-server-go/README.md @@ -25,6 +25,10 @@ cd ../mcp-client-go go run main.go ../weather-server-go/weather ``` +## Tool annotations + +Both tools declare the same [annotations](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations): `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and `openWorldHint: true`. They tell a host that the tools only read data, and that they read it from an outside service (the NWS API), so a host can, for example, run them without asking the user to confirm each call. Annotations are hints, not guarantees: a host should rely on them only for servers it trusts. + ## Structured content Both tools advertise an `outputSchema` and return `StructuredContent`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) in the spec. diff --git a/weather-server-go/main.go b/weather-server-go/main.go index 5b8e4185..39d4b745 100644 --- a/weather-server-go/main.go +++ b/weather-server-go/main.go @@ -259,6 +259,19 @@ func alertsOutputSchema() *jsonschema.Schema { return schema } +// weatherToolAnnotations returns hints for hosts: both tools only read data, +// and they read it from an outside service (the NWS API). Hints are not +// guarantees; a host decides how far to trust them. +func weatherToolAnnotations() *mcp.ToolAnnotations { + destructive, openWorld := false, true + return &mcp.ToolAnnotations{ + ReadOnlyHint: true, + DestructiveHint: &destructive, + IdempotentHint: true, + OpenWorldHint: &openWorld, + } +} + func main() { // Create MCP server. // @@ -277,6 +290,7 @@ func main() { mcp.AddTool(server, &mcp.Tool{ Name: "get_forecast", Description: "Get weather forecast for a location", + Annotations: weatherToolAnnotations(), }, getForecast) // Add get_alerts tool @@ -284,6 +298,7 @@ func main() { Name: "get_alerts", Description: "Get weather alerts for a US state", OutputSchema: alertsOutputSchema(), + Annotations: weatherToolAnnotations(), }, getAlerts) // Run server on stdio transport diff --git a/weather-server-python/README.md b/weather-server-python/README.md index 732e426a..b0685e69 100644 --- a/weather-server-python/README.md +++ b/weather-server-python/README.md @@ -27,6 +27,10 @@ cd ../mcp-client-python uv run client.py ../weather-server-python/weather.py ``` +## Tool annotations + +Both tools declare the same [annotations](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations): `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and `openWorldHint: true`. They tell a host that the tools only read data, and that they read it from an outside service (the NWS API), so a host can, for example, run them without asking the user to confirm each call. Annotations are hints, not guarantees: a host should rely on them only for servers it trusts. + ## Structured content Both tools declare an output schema and return `structured_content`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) in the spec. diff --git a/weather-server-python/weather.py b/weather-server-python/weather.py index b6b47705..65c0780f 100644 --- a/weather-server-python/weather.py +++ b/weather-server-python/weather.py @@ -3,6 +3,7 @@ import httpx2 from mcp.server import MCPServer from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import ToolAnnotations from pydantic import BaseModel, Field, RootModel # Initialize the MCP server @@ -12,6 +13,16 @@ NWS_API_BASE = "https://api.weather.gov" USER_AGENT = "weather-app/1.0" +# Hints for hosts: both tools only read data, and they read it from an outside +# service (the NWS API). Hints are not guarantees; a host decides how far to +# trust them. +WEATHER_TOOL_ANNOTATIONS = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, +) + class Alert(BaseModel): """One active weather alert.""" @@ -64,7 +75,7 @@ async def make_nws_request(url: str) -> dict[str, Any] | None: return None -@mcp.tool() +@mcp.tool(annotations=WEATHER_TOOL_ANNOTATIONS) async def get_alerts(state: str) -> Alerts: """Get weather alerts for a US state. @@ -95,7 +106,7 @@ async def get_alerts(state: str) -> Alerts: ) -@mcp.tool() +@mcp.tool(annotations=WEATHER_TOOL_ANNOTATIONS) async def get_forecast(latitude: float, longitude: float) -> Forecast: """Get weather forecast for a location. diff --git a/weather-server-ruby/README.md b/weather-server-ruby/README.md index 72def02d..25d40e44 100644 --- a/weather-server-ruby/README.md +++ b/weather-server-ruby/README.md @@ -27,6 +27,10 @@ bundle install bundle exec ruby client.rb ../weather-server-ruby/weather.rb ``` +## Tool annotations + +Both tools declare the same [annotations](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations): `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and `openWorldHint: true`. They tell a host that the tools only read data, and that they read it from an outside service (the NWS API), so a host can, for example, run them without asking the user to confirm each call. Annotations are hints, not guarantees: a host should rely on them only for servers it trusts. + ## Structured content Both tools declare an `output_schema` and return `structured_content`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) in the spec. diff --git a/weather-server-ruby/weather.rb b/weather-server-ruby/weather.rb index 22d5761d..6127d38a 100644 --- a/weather-server-ruby/weather.rb +++ b/weather-server-ruby/weather.rb @@ -8,6 +8,17 @@ NWS_API_BASE = "https://api.weather.gov" USER_AGENT = "weather-app/1.0" +# Hints for hosts: both tools only read data, and they read it from an outside +# service (the NWS API). Hints are not guarantees; a host decides how far to +# trust them. The gem sends all four hints, so set each one explicitly: its +# defaults (destructive, not idempotent) suit a tool that writes. +WEATHER_TOOL_ANNOTATIONS = { + read_only_hint: true, + destructive_hint: false, + idempotent_hint: true, + open_world_hint: true +}.freeze + module HelperMethods def make_nws_request(url) uri = URI(url) @@ -47,6 +58,7 @@ class GetAlerts < MCP::Tool tool_name "get_alerts" description "Get weather alerts for a US state" + annotations(**WEATHER_TOOL_ANNOTATIONS) input_schema( properties: { state: { @@ -116,6 +128,7 @@ class GetForecast < MCP::Tool tool_name "get_forecast" description "Get weather forecast for a location" + annotations(**WEATHER_TOOL_ANNOTATIONS) input_schema( properties: { latitude: { diff --git a/weather-server-rust/README.md b/weather-server-rust/README.md index 4646c193..afae6fc2 100644 --- a/weather-server-rust/README.md +++ b/weather-server-rust/README.md @@ -31,6 +31,10 @@ cd ../mcp-client-rust cargo run -- ../weather-server-rust/target/release/weather ``` +## Tool annotations + +Both tools declare the same [annotations](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations): `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and `openWorldHint: true`. They tell a host that the tools only read data, and that they read it from an outside service (the NWS API), so a host can, for example, run them without asking the user to confirm each call. Annotations are hints, not guarantees: a host should rely on them only for servers it trusts. + ## Structured content Both tools declare an `output_schema` and return `structured_content`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) in the spec. diff --git a/weather-server-rust/src/main.rs b/weather-server-rust/src/main.rs index 4d4e7735..ad026ba1 100644 --- a/weather-server-rust/src/main.rs +++ b/weather-server-rust/src/main.rs @@ -169,6 +169,9 @@ pub struct Weather { tool_router: ToolRouter, } +// Both tools carry the same annotations, hints for hosts: they only read data, +// and they read it from an outside service (the NWS API). Hints are not +// guarantees; a host decides how far to trust them. #[tool_router(router = tool_router)] impl Weather { fn new() -> Self { @@ -179,6 +182,12 @@ impl Weather { #[tool( description = "Get weather alerts for a US state.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ), output_schema = schema_for_output::>() )] async fn get_alerts( @@ -229,6 +238,12 @@ impl Weather { #[tool( description = "Get weather forecast for a location.", + annotations( + read_only_hint = true, + destructive_hint = false, + idempotent_hint = true, + open_world_hint = true + ), output_schema = schema_for_output::() )] async fn get_forecast( diff --git a/weather-server-typescript/README.md b/weather-server-typescript/README.md index 75891115..51b8f970 100644 --- a/weather-server-typescript/README.md +++ b/weather-server-typescript/README.md @@ -28,6 +28,10 @@ npm install && npm run build node build/index.js ../weather-server-typescript/build/index.js ``` +## Tool annotations + +Both tools declare the same [annotations](https://modelcontextprotocol.io/specification/2026-07-28/schema#toolannotations): `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and `openWorldHint: true`. They tell a host that the tools only read data, and that they read it from an outside service (the NWS API), so a host can, for example, run them without asking the user to confirm each call. Annotations are hints, not guarantees: a host should rely on them only for servers it trusts. + ## Structured content Both tools declare an `outputSchema` and return `structuredContent`. `get-forecast` returns an object; `get-alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content) in the spec. diff --git a/weather-server-typescript/src/index.ts b/weather-server-typescript/src/index.ts index a003a53c..3a165788 100644 --- a/weather-server-typescript/src/index.ts +++ b/weather-server-typescript/src/index.ts @@ -119,6 +119,16 @@ function formatPeriod(period: Forecast["periods"][number]): string { ].join("\n"); } +// Hints for hosts: both tools only read data, and they read it from an outside +// service (the NWS API). Hints are not guarantees; a host decides how far to +// trust them. +const weatherToolAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +}; + function buildServer(): McpServer { const server = new McpServer({ name: "weather", @@ -129,6 +139,7 @@ function buildServer(): McpServer { "get-alerts", { title: "Get Weather Alerts", + annotations: weatherToolAnnotations, description: "Get weather alerts for a state", inputSchema: z.object({ state: z @@ -172,6 +183,7 @@ function buildServer(): McpServer { "get-forecast", { title: "Get Weather Forecast", + annotations: weatherToolAnnotations, description: "Get weather forecast for a location", inputSchema: z.object({ latitude: z