Skip to content

Commit 3b3ff7c

Browse files
committed
docs: name the JSON Schema dialect and show 2020-12 keywords by hand
Nothing in the narrative docs said which JSON Schema dialect tool schemas use. Add "The dialect is JSON Schema 2020-12" to the low-level server page, where readers actually hand-write input_schema and output_schema: MCP's no-$schema-means-2020-12 rule, a find_book tool using oneOf and a prefixItems tuple, the Client validating structured_content by the same rule, the draft-07 `items: [...]` trap and its two fixes, and the legacy-client requirement for object roots. tools.md gets a one-sentence pointer where most readers first meet a schema. No-Verification-Needed: docs, docs_src examples and tests only
1 parent c7c0be9 commit 3b3ff7c

4 files changed

Lines changed: 312 additions & 3 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,46 @@ The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-
111111

112112
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
113113

114+
## The dialect is JSON Schema 2020-12
115+
116+
`input_schema` and `output_schema` are JSON Schema, and the [MCP specification](https://modelcontextprotocol.io/specification/latest/basic#json-schema-usage) says which dialect: a schema with no `$schema` key is **JSON Schema 2020-12**. `MCPServer` relies on that default, since Pydantic generates 2020-12 from your type hints and omits the key. On the low-level `Server` it is the dialect your hand-written dict is held to, so every 2020-12 keyword is available for shapes a function signature cannot express. This lookup takes an ISBN *or* a title and author, and answers with where the book is shelved:
117+
118+
```python title="server.py" hl_lines="15 21-22 30-31"
119+
--8<-- "docs_src/lowlevel/tutorial007.py"
120+
```
121+
122+
* `"type": "object"` at the root of `input_schema` is the one fixed point, on every protocol version, because a tool's arguments are always a JSON object. Leave it out and `list_tools` itself fails for every client with `MCPError: Handler returned an invalid result` (the reason is in the server's log, not in the error).
123+
* `oneOf` and `"additionalProperties": false` sit beside that root `type`, and any other 2020-12 keyword may join them: `anyOf`, `allOf`, `not`, `if`/`then`/`else`, `dependentRequired`, `$defs` with local `$ref`s. Clients on either protocol version, `2026-07-28` or legacy, receive them as you wrote them.
124+
* `prefixItems`, closed with `"items": false`, is how 2020-12 says "a string, then an integer, then nothing else". Older drafts spelled tuples differently. `MCPServer` writes `prefixItems` too, whenever a signature says `tuple[str, int]`.
125+
* Neither dict carries a `$schema` key, and neither needs one.
126+
127+
Call `find_book` with `{"isbn": "9780441172719"}` and the result carries both representations:
128+
129+
```python
130+
result.content # [TextContent(type='text', text="'Dune' is on shelf C-3.")]
131+
result.structured_content # {'title': 'Dune', 'shelf': ['C', 3]}
132+
```
133+
134+
The server applied neither schema: `oneOf` is advertised like the rest of `input_schema`, and checking `params.arguments` is still your job. The dialect matters on the other side. This SDK's `Client` validates `structured_content` against `output_schema`, and it picks its validator by the same rule: 2020-12 when there is no `$schema`, the declared dialect when there is one. That is what held `shelf` to a string followed by an integer before `call_tool` returned.
135+
136+
!!! check
137+
In `output_schema`, spell the `shelf` tuple the way draft-07 did,
138+
`"items": [{"type": "string"}, {"type": "integer"}]`, and leave `$schema` out. `list_tools`
139+
still hands the dict over unchanged, because listing never interprets a schema. The first
140+
`call_tool` does: the `Client` builds a 2020-12 validator from `output_schema`, and in 2020-12
141+
`items` must be a single schema, not a list, so the call raises before `structured_content` is
142+
validated. The error opens with:
143+
144+
```text
145+
RuntimeError: Invalid schema for tool find_book: [{'type': 'string'}, {'type': 'integer'}] is not of type 'object', 'boolean'
146+
```
147+
148+
Add `"$schema": "http://json-schema.org/draft-07/schema#"` to that `output_schema` and the same
149+
call succeeds, validated as draft-07. Better still, write `prefixItems`: 2020-12 is the one
150+
dialect every MCP client and server is required to understand.
151+
152+
The protocol version, not the dialect, decides one more thing: what may sit at the root of `output_schema`. A `2026-07-28` client accepts an `output_schema` describing any JSON value, an array or a bare string included. A client on a legacy connection (`2025-11-25` or earlier; **[Serving legacy clients](../run/legacy-clients.md)**) needs `"type": "object"` at the root of `output_schema` too, and its `list_tools` fails with the same `Handler returned an invalid result` if you advertise anything else. Keep `"type": "object"` at both roots and every client can list your tools. `MCPServer` never hits this, because it wraps any return type that isn't an object in `{"result": ...}`.
153+
114154
## `_meta`: for the application, not the model
115155

116156
`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all.
@@ -187,13 +227,14 @@ Each of these is one idea you now have the vocabulary for; each has its own page
187227
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
188228
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
189229
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
190-
* `on_roots_list_changed=` receives `notifications/roots/list_changed` from a 2025-era client. It is deprecated with the rest of roots and passing it warns at construction; **[Deprecated features](../deprecated.md#roots-change-notifications)** runs it end to end.
230+
* `on_roots_list_changed` receives `notifications/roots/list_changed` from a 2025-era client. It is deprecated with the rest of the roots capability and passing it warns at construction; **[Deprecated features](../deprecated.md#roots-change-notifications)** runs it end to end.
191231
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
192232

193233
## Recap
194234

195235
* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`.
196236
* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you.
237+
* A schema without a `$schema` key is **JSON Schema 2020-12**, so any 2020-12 keyword may sit beside the mandatory root `"type": "object"`. The `Client` validates `structured_content` against `output_schema` as 2020-12 unless `$schema` names another dialect.
197238
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
198239
* `_meta` on the result is addressed to the client application, not the model.
199240
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.

docs/servers/tools.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ From those type hints the SDK generates a JSON Schema and sends it to the client
3434

3535
Both arguments are in `required` because neither has a default. You'll fix that in a moment. (The `title` keys are Pydantic artifacts; the properties, their types, and `required` are the contract.)
3636

37+
There is no `$schema` key either: MCP treats a schema without one as **JSON Schema 2020-12**, which is what Pydantic generates, so there is nothing to choose until you write schemas by hand on the **[low-level Server](../advanced/low-level-server.md#the-dialect-is-json-schema-2020-12)**.
38+
3739
!!! tip
3840
Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`,
3941
the SDK rejects it before your function ever runs.

docs_src/lowlevel/tutorial007.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from mcp.server import Server, ServerRequestContext
2+
from mcp.types import (
3+
CallToolRequestParams,
4+
CallToolResult,
5+
ListToolsResult,
6+
PaginatedRequestParams,
7+
TextContent,
8+
Tool,
9+
)
10+
11+
FIND_BOOK = Tool(
12+
name="find_book",
13+
description="Find one book by ISBN, or by title and author.",
14+
input_schema={
15+
"type": "object",
16+
"properties": {
17+
"isbn": {"type": "string", "pattern": "^[0-9]{13}$"},
18+
"title": {"type": "string"},
19+
"author": {"type": "string"},
20+
},
21+
"oneOf": [{"required": ["isbn"]}, {"required": ["title", "author"]}],
22+
"additionalProperties": False,
23+
},
24+
output_schema={
25+
"type": "object",
26+
"properties": {
27+
"title": {"type": "string"},
28+
"shelf": {
29+
"type": "array",
30+
"prefixItems": [{"type": "string"}, {"type": "integer"}],
31+
"items": False,
32+
},
33+
},
34+
"required": ["title", "shelf"],
35+
},
36+
)
37+
38+
BY_ISBN = {"9780441172719": "Dune"}
39+
40+
41+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
42+
return ListToolsResult(tools=[FIND_BOOK])
43+
44+
45+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
46+
args = params.arguments or {}
47+
title = BY_ISBN[args["isbn"]] if "isbn" in args else args["title"]
48+
return CallToolResult(
49+
content=[TextContent(type="text", text=f"{title!r} is on shelf C-3.")],
50+
structured_content={"title": title, "shelf": ["C", 3]},
51+
)
52+
53+
54+
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)

0 commit comments

Comments
 (0)