Skip to content

Commit 99c7e8a

Browse files
committed
docs: validate the tool name in the whats-new lowlevel example
One call_tool handler serves every tool, so both halves of the before/after now check the name, and the check doubles as the live demo of the error-polarity contrast: v1 raises ValueError because the message comes back as CallToolResult(isError=True) text the model reads; v2 raises MCPError(INVALID_PARAMS, ...) because a bare exception would be sanitized to -32603, and -32602 with this message is the spec's own answer for an unknown tool. New test proves the raised MCPError passes through with code and message intact.
1 parent 66e8c3c commit 99c7e8a

3 files changed

Lines changed: 31 additions & 13 deletions

File tree

docs/whats-new.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,19 @@ async def list_tools() -> list[types.Tool]:
7272

7373
@server.call_tool()
7474
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)!
75-
ctx = server.request_context # (5)!
76-
return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (6)!
75+
if name != "search_books":
76+
raise ValueError(f"Unknown tool: {name}") # (5)!
77+
ctx = server.request_context # (6)!
78+
return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)!
7779
```
7880

7981
1. Handlers are registered with decorators (called, with parentheses), any time after the server exists.
8082
2. You return a bare `list[Tool]` and the SDK wraps it into a `ListToolsResult`.
8183
3. Fields are camelCase in Python, and the schema is **enforced**: the SDK jsonschema-validates `call_tool` arguments against it before your function runs, which is why `arguments["query"]` below is safe.
82-
4. The handler receives the tool name and the already-validated arguments, unpacked and never `None`.
83-
5. The context comes from an ambient ContextVar, reached through the server object mid-request.
84-
6. Bare content blocks are wrapped into a `CallToolResult` for you, and an exception raised anywhere in the handler is caught and returned as `CallToolResult(isError=True)` with `str(e)` as its text: the calling model reads your exception messages.
84+
4. One `call_tool` handler serves every tool, and it receives the tool name and the already-validated arguments, unpacked and never `None`.
85+
5. Raising is how a v1 tool signals failure: any exception is caught and returned as `CallToolResult(isError=True)` with `str(e)` as its text, so the calling model reads this message and can retry.
86+
6. The context comes from an ambient ContextVar, reached through the server object mid-request.
87+
7. Bare content blocks are wrapped into a `CallToolResult` for you.
8588

8689
```python title="v2"
8790
--8<-- "docs_src/whats_new/tutorial001.py"
@@ -91,9 +94,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
9194
2. Every handler has the same shape: `async (ctx, params) -> result`. The context is the first argument (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` live on it); this is where `server.request_context` went.
9295
3. You build the full `ListToolsResult` yourself. Returning a bare list is a server-side `TypeError` now, not something the SDK wraps.
9396
4. Typed params in (`params.name`, `params.arguments`), a full result out. Nothing is unpacked, wrapped, or converted for you.
94-
5. `params.arguments` can be `None`; v1 defaulted it to `{}` before your code ever saw it. With no validation in front of the handler, this line is load-bearing.
95-
6. An exception raised here becomes a sanitized protocol error (`-32603`, `"Internal server error"`): the model never sees the message. For a failure the model should read and react to, return `CallToolResult(is_error=True, ...)`; for a specific wire error, raise `MCPError(code, message)`.
96-
7. Handlers are constructor arguments, so the server's surface is complete the moment it exists; `add_request_handler()` is the post-construction escape hatch, and the door to custom methods.
97+
5. Same check, different verb. A `ValueError` here would reach the model as an opaque `-32603` (see below), so a deliberate wire error is raised as `MCPError`: it passes through with its code and message intact, and `-32602` with this text is the spec's own answer for an unknown tool.
98+
6. `params.arguments` can be `None`; v1 defaulted it to `{}` before your code ever saw it. With no validation in front of the handler, this line is load-bearing.
99+
7. An unexpected exception raised here becomes a **sanitized** protocol error, `-32603` `"Internal server error"`: the model never sees the message. For a failure the model should read and react to, return `CallToolResult(is_error=True, ...)`.
100+
8. Handlers are constructor arguments, so the server's surface is complete the moment it exists; `add_request_handler()` is the post-construction escape hatch, and the door to custom methods.
97101

98102
The example is the pattern. More generally: every handler has the same shape, with typed params in and a full result type out; the old jsonschema check of tool arguments is gone; an exception is a protocol error, never an `is_error=True` tool result; and the ambient `server.request_context` ContextVar is gone. Custom, vendor-namespaced methods are first class through `add_request_handler(method, params_type, handler)`, which validates inbound params against your model before your handler runs. And a `middleware` list (deliberately marked provisional) wraps every inbound message, replacing the private `_handle_*` methods people used to override.
99103

docs_src/whats_new/tutorial001.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from mcp_types import (
2+
INVALID_PARAMS,
23
CallToolRequestParams,
34
CallToolResult,
45
ListToolsResult,
@@ -7,6 +8,7 @@
78
Tool,
89
)
910

11+
from mcp import MCPError
1012
from mcp.server import Server, ServerRequestContext
1113

1214
SEARCH_BOOKS = Tool(
@@ -25,9 +27,11 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
2527

2628

2729
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # (4)!
28-
args = params.arguments or {} # (5)!
30+
if params.name != "search_books":
31+
raise MCPError(INVALID_PARAMS, f"Unknown tool: {params.name}") # (5)!
32+
args = params.arguments or {} # (6)!
2933
text = f"Found 3 books matching {args['query']!r}."
30-
return CallToolResult(content=[TextContent(type="text", text=text)]) # (6)!
34+
return CallToolResult(content=[TextContent(type="text", text=text)]) # (7)!
3135

3236

33-
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) # (7)!
37+
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) # (8)!

tests/docs_src/test_whats_new.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
import pytest
8-
from mcp_types import INTERNAL_ERROR, TextContent
8+
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, TextContent
99

1010
from docs_src.whats_new import tutorial001
1111
from mcp import Client, MCPError
@@ -35,7 +35,7 @@ async def test_a_valid_call_answers() -> None:
3535

3636

3737
async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized() -> None:
38-
"""Annotations 1, 5, and 6, in one flow.
38+
"""Annotations 1, 6, and 7, in one flow.
3939
4040
A call missing the required `query` REACHES the handler (nothing validates
4141
arguments against `input_schema`; v1 rejected this call before the handler
@@ -53,3 +53,13 @@ async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized(
5353
await client.call_tool("search_books")
5454
assert excinfo.value.code == INTERNAL_ERROR
5555
assert excinfo.value.message == "Internal server error"
56+
57+
58+
async def test_an_unknown_tool_is_a_deliberate_wire_error() -> None:
59+
"""Annotation 5: a raised `MCPError` passes through with its code and message
60+
intact (the spec's answer for an unknown tool), unlike the sanitized path."""
61+
async with Client(tutorial001.server) as client:
62+
with pytest.raises(MCPError) as excinfo:
63+
await client.call_tool("shelve_book", {"query": "dune"})
64+
assert excinfo.value.code == INVALID_PARAMS
65+
assert excinfo.value.message == "Unknown tool: shelve_book"

0 commit comments

Comments
 (0)