Skip to content

Commit 66e8c3c

Browse files
committed
docs: annotated low-level before/after example on the whats-new page
Show the same one-tool server written for v1 and for v2 in the 'rebuilt, not renamed' section, with code annotations carrying the contrasts: how the request context is reached (ambient ContextVar vs the ctx argument), return wrapping (bare lists vs full result models), argument validation (enforced jsonschema in v1 vs advertised-only in v2), exception polarity (isError=True text the model reads vs a sanitized -32603 protocol error), and registration (decorators vs constructor arguments). The v2 half is a tested file, docs_src/whats_new/tutorial001.py, driven end to end by tests/docs_src/test_whats_new.py (schema advertised verbatim; a call missing a required argument reaches the handler and comes back as the sanitized protocol error). The v1 half cannot import in CI, so it stays an inline fence with a comment recording its ground truth: the exact code was run verbatim against a real mcp==1.28.1 install.
1 parent ee0f551 commit 66e8c3c

4 files changed

Lines changed: 144 additions & 1 deletion

File tree

docs/whats-new.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,62 @@ v1 handed you three nested layers: a transport context manager yielding raw stre
4040

4141
### The low-level `Server` was rebuilt, not renamed
4242

43-
If you work at the JSON-RPC layer, this is the "everything is different" part of v2. Handlers are **constructor arguments**, not decorators: every one has the same shape, `async (ctx, params) -> result`, with typed params in and a full result type out. Nothing is wrapped, converted, or inferred for you any more, the old jsonschema check of tool arguments against your `input_schema` is gone, and an exception is a protocol error, never an `is_error=True` tool result. The ambient `server.request_context` ContextVar is gone; the context is the `ctx` argument. 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.
43+
If you work at the JSON-RPC layer, this is the "everything is different" part of v2. Here is the same one-tool server both ways; click the markers for what moved.
44+
45+
<!-- The v1 fence cannot be a tested docs_src file (nothing in CI can import the
46+
1.x SDK). Its ground truth: this exact code was run verbatim against a real
47+
mcp==1.28.1 install. If you edit it, re-validate it against 1.x. -->
48+
49+
```python title="v1"
50+
from typing import Any
51+
52+
import mcp.types as types
53+
from mcp.server.lowlevel import Server
54+
55+
server = Server("Bookshop")
56+
57+
58+
@server.list_tools() # (1)!
59+
async def list_tools() -> list[types.Tool]:
60+
return [ # (2)!
61+
types.Tool(
62+
name="search_books",
63+
description="Search the catalog by title or author.",
64+
inputSchema={ # (3)!
65+
"type": "object",
66+
"properties": {"query": {"type": "string"}},
67+
"required": ["query"],
68+
},
69+
)
70+
]
71+
72+
73+
@server.call_tool()
74+
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)!
77+
```
78+
79+
1. Handlers are registered with decorators (called, with parentheses), any time after the server exists.
80+
2. You return a bare `list[Tool]` and the SDK wraps it into a `ListToolsResult`.
81+
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.
85+
86+
```python title="v2"
87+
--8<-- "docs_src/whats_new/tutorial001.py"
88+
```
89+
90+
1. Fields are snake_case now, and the schema is **advertised but never applied**: nothing checks the arguments before your handler runs.
91+
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.
92+
3. You build the full `ListToolsResult` yourself. Returning a bare list is a server-side `TypeError` now, not something the SDK wraps.
93+
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+
98+
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.
4499

45100
Underneath, the v1 `BaseSession` receive loop was replaced by a dispatcher engine that the client and the server now share, and it is what makes several things on this page true at once: one `Server` object serves both protocol eras, `Client(server)` dispatches in process with no JSON-RPC framing, and a timed-out client request now actually cancels the server-side handler.
46101

docs_src/whats_new/__init__.py

Whitespace-only changes.

docs_src/whats_new/tutorial001.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from mcp_types import (
2+
CallToolRequestParams,
3+
CallToolResult,
4+
ListToolsResult,
5+
PaginatedRequestParams,
6+
TextContent,
7+
Tool,
8+
)
9+
10+
from mcp.server import Server, ServerRequestContext
11+
12+
SEARCH_BOOKS = Tool(
13+
name="search_books",
14+
description="Search the catalog by title or author.",
15+
input_schema={ # (1)!
16+
"type": "object",
17+
"properties": {"query": {"type": "string"}},
18+
"required": ["query"],
19+
},
20+
)
21+
22+
23+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: # (2)!
24+
return ListToolsResult(tools=[SEARCH_BOOKS]) # (3)!
25+
26+
27+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # (4)!
28+
args = params.arguments or {} # (5)!
29+
text = f"Found 3 books matching {args['query']!r}."
30+
return CallToolResult(content=[TextContent(type="text", text=text)]) # (6)!
31+
32+
33+
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) # (7)!

tests/docs_src/test_whats_new.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""`docs/whats-new.md`: the v2 half of the low-level before/after example, proved against the real SDK.
2+
3+
The v1 half of that example targets the 1.x line and cannot run here; it was
4+
validated by running it verbatim against a real `mcp==1.28.1` install.
5+
"""
6+
7+
import pytest
8+
from mcp_types import INTERNAL_ERROR, TextContent
9+
10+
from docs_src.whats_new import tutorial001
11+
from mcp import Client, MCPError
12+
13+
# See test_index.py for why this is a per-module mark and not a conftest hook.
14+
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
15+
16+
17+
async def test_the_advertised_schema_is_the_literal_dict() -> None:
18+
"""Annotation 1: the schema is advertised to clients exactly as written."""
19+
async with Client(tutorial001.server) as client:
20+
(tool,) = (await client.list_tools()).tools
21+
assert tool.name == "search_books"
22+
assert tool.input_schema == {
23+
"type": "object",
24+
"properties": {"query": {"type": "string"}},
25+
"required": ["query"],
26+
}
27+
28+
29+
async def test_a_valid_call_answers() -> None:
30+
"""The example works end to end through the in-process `Client`."""
31+
async with Client(tutorial001.server) as client:
32+
result = await client.call_tool("search_books", {"query": "dune"})
33+
assert not result.is_error
34+
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
35+
36+
37+
async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized() -> None:
38+
"""Annotations 1, 5, and 6, in one flow.
39+
40+
A call missing the required `query` REACHES the handler (nothing validates
41+
arguments against `input_schema`; v1 rejected this call before the handler
42+
ran). The handler's own `KeyError` then comes back as a sanitized protocol
43+
error, never an `is_error=True` result the model could read. A call with no
44+
arguments at all exercises `params.arguments or {}` the same way.
45+
"""
46+
async with Client(tutorial001.server) as client:
47+
with pytest.raises(MCPError) as excinfo:
48+
await client.call_tool("search_books", {"limit": 5})
49+
assert excinfo.value.code == INTERNAL_ERROR
50+
assert excinfo.value.message == "Internal server error"
51+
52+
with pytest.raises(MCPError) as excinfo:
53+
await client.call_tool("search_books")
54+
assert excinfo.value.code == INTERNAL_ERROR
55+
assert excinfo.value.message == "Internal server error"

0 commit comments

Comments
 (0)