You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: docs/advanced/low-level-server.md
+42-1Lines changed: 42 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -111,6 +111,46 @@ The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-
111
111
112
112
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)**.
113
113
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:
*`"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.")]
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
+
114
154
## `_meta`: for the application, not the model
115
155
116
156
`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
187
227
*`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)**).
188
228
*`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.
189
229
*`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.
191
231
*`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.
192
232
193
233
## Recap
194
234
195
235
* The low-level `Server` takes its handlers as `on_*`**constructor parameters**; every handler is `async (ctx, params) -> result`.
196
236
* 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.
197
238
* 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.
198
239
*`_meta` on the result is addressed to the client application, not the model.
199
240
*`Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
Copy file name to clipboardExpand all lines: docs/servers/tools.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -34,6 +34,8 @@ From those type hints the SDK generates a JSON Schema and sends it to the client
34
34
35
35
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.)
36
36
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
+
37
39
!!! tip
38
40
Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`,
39
41
the SDK rejects it before your function ever runs.
0 commit comments