Skip to content

Commit 88e497e

Browse files
committed
docs: cover the remaining Tier 1 audit items
Adds the narrative coverage and examples the SEP-1730 docs audit still flagged as missing on main: a tool returning an EmbeddedResource (media page), prompt messages carrying an embedded file and an image plus adding prompts at runtime with the list-changed notification (prompts page), ping and roots change notifications on a legacy session (deprecated-features page), and the JSON Schema 2020-12 dialect (low-level server page, with a pointer from tools). Docs and docs_src only. No-Verification-Needed: docs and docs_src examples only
1 parent fb443cc commit 88e497e

10 files changed

Lines changed: 273 additions & 1 deletion

File tree

docs/advanced/low-level-server.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ 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) fixes the dialect: a schema with no `$schema` key is **JSON Schema 2020-12**. The schemas `MCPServer` generates rely on that default (Pydantic writes 2020-12 and omits the key), and a hand-written dict is held to it too, so the full 2020-12 vocabulary is available:
117+
118+
```python title="server.py" hl_lines="8 14-15"
119+
--8<-- "docs_src/lowlevel/tutorial007.py"
120+
```
121+
122+
* The root of `input_schema` must be `"type": "object"`. Beside it, `oneOf`, `additionalProperties`, `anyOf`, `if`/`then`/`else`, `prefixItems`, `$defs` with local `$ref`s and the rest of the 2020-12 keywords reach the client exactly as written.
123+
* No `$schema` key is needed. Add one only to opt into an older draft: this SDK's `Client`, which validates `structured_content` against a tool's `output_schema`, picks its validator from `$schema` and uses 2020-12 when there is none.
124+
114125
## `_meta`: for the application, not the model
115126

116127
`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.

docs/deprecated.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,85 @@ MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SE
5050
send. These two only work end-to-end on a `mode="legacy"` connection whose client
5151
registered the matching callback.
5252

53+
## `ping` on a legacy session
54+
55+
A **ping** is an empty request either side can send to check that the other is still answering. The 2026-07-28 spec removes it ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)): every request a modern client sends already proves the server is there, and a modern server has no channel to send one. Both SDK methods still work on a handshake-era session:
56+
57+
```python title="client.py" hl_lines="11 16-17"
58+
from mcp import Client
59+
from mcp.server import MCPServer
60+
from mcp.server.mcpserver import Context
61+
62+
mcp = MCPServer("Bookshop")
63+
64+
65+
@mcp.tool()
66+
async def check_client(ctx: Context) -> str:
67+
"""A tool that still pings the client mid-call."""
68+
await ctx.session.send_ping()
69+
return "client answered"
70+
71+
72+
async def main() -> None:
73+
async with Client(mcp, mode="legacy") as client:
74+
await client.send_ping()
75+
result = await client.call_tool("check_client", {})
76+
print(client.protocol_version, result.structured_content) # 2025-11-25 {'result': 'client answered'}
77+
```
78+
79+
* `client.send_ping()` is the client asking the server. It warns with `MCPDeprecationWarning` on every call and returns an `EmptyResult`; on a default (`2026-07-28`) connection the server answers `MCPError: Method not found` instead.
80+
* `ctx.session.send_ping()` is the server asking the client from inside a handler. It carries no warning; on a modern connection it raises the same no-back-channel error as any other server-initiated request.
81+
* Neither side registers anything to answer a ping. `mode="legacy"` is what makes both round trips possible.
82+
83+
## Roots change notifications
84+
85+
A 2025-era client that declared the roots capability can tell the server that its workspace folders changed by sending `notifications/roots/list_changed`; the server responds by requesting `roots/list` again. The 2026-07-28 spec removes the notification along with the rest of the push-style roots flow, and `MCPServer` has no hook for it, so this is a legacy client talking to a low-level `Server`, both in one module:
86+
87+
```python title="client.py" hl_lines="12 19 32 36"
88+
import anyio
89+
from pydantic import FileUrl
90+
91+
from mcp import Client
92+
from mcp.client import ClientRequestContext
93+
from mcp.server import Server, ServerRequestContext
94+
from mcp.types import ListRootsResult, NotificationParams, Root
95+
96+
workspace = [Root(uri=FileUrl("file:///home/ada/catalog"), name="catalog")]
97+
98+
99+
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
100+
return ListRootsResult(roots=workspace)
101+
102+
103+
async def open_folder(client: Client, uri: str, name: str) -> None:
104+
"""The user opened another folder: expose it, then tell the server."""
105+
workspace.append(Root(uri=FileUrl(uri), name=name))
106+
await client.send_roots_list_changed()
107+
108+
109+
workspace_folders: list[str] = []
110+
111+
112+
async def roots_changed(ctx: ServerRequestContext, params: NotificationParams | None) -> None:
113+
"""The client's roots changed: ask for the new list."""
114+
result = await ctx.session.list_roots()
115+
workspace_folders[:] = [str(root.uri) for root in result.roots]
116+
print(workspace_folders) # ['file:///home/ada/catalog', 'file:///home/ada/archive']
117+
118+
119+
server = Server("Bookshop", on_roots_list_changed=roots_changed)
120+
121+
122+
async def main() -> None:
123+
async with Client(server, mode="legacy", list_roots_callback=list_roots) as client:
124+
await open_folder(client, "file:///home/ada/archive", "archive")
125+
await anyio.sleep_forever() # keep the session open: the server's roots/list arrives on it
126+
```
127+
128+
* Passing `list_roots_callback=` declares `"roots": {"listChanged": true}`, and `client.send_roots_list_changed()` keeps that promise. It warns, and it needs `mode="legacy"`: on a modern connection the notification is silently dropped.
129+
* `Server(..., on_roots_list_changed=...)` registers the receiving handler (deprecated too, and it warns at construction). The notification carries no payload, so the handler calls `ctx.session.list_roots()` to fetch the new list.
130+
* A notification has no response, so keep the session open after sending it, as `main()` does; the server's follow-up `roots/list` request arrives on that session and the handler prints the refreshed list.
131+
53132
## Silencing the warning
54133

55134
Don't, in new code.

docs/servers/media.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ A suffix it doesn't recognise falls back to `application/octet-stream`.
8181
`Audio` from MP3 bytes that way and the client is told `mime_type="audio/wav"`, then
8282
faithfully fails to decode it. When you pass `data=`, pass `format=`.
8383

84+
## Embedding a resource
85+
86+
A tool can also return a document: some text or bytes together with the URI it lives at and a MIME type. That is an **`EmbeddedResource`**, another kind of content block. Unlike a plain `str` it tells the client what the content is, so the client can show it as an attachment or recognise a resource it already knows.
87+
88+
```python title="server.py" hl_lines="7 14 16-18"
89+
--8<-- "docs_src/media/tutorial005.py"
90+
```
91+
92+
* `brand://guidelines` is an ordinary resource (**[Resources](resources.md)** covers those). The tool hands the same document to the model on request, and calling `guidelines()` directly keeps one source of truth.
93+
* `EmbeddedResource` and `TextResourceContents` come from `mcp.types`. There is no helper as there is for images: the block you build goes into the result untouched, and there is no `structured_content`.
94+
* Use the URI the resource is registered under, so a client can tell that the attachment and `brand://guidelines` are the same document. Any URI is legal, registered or not.
95+
96+
```python
97+
result.content # [EmbeddedResource(type="resource", resource=TextResourceContents(uri="brand://guidelines", mime_type="text/markdown", text="# Brand guidelines\n\n..."))]
98+
```
99+
100+
For binary content, use `BlobResourceContents(uri=..., mime_type=..., blob=...)` with the bytes base64-encoded into `blob`, in place of `TextResourceContents`. To send only a pointer the client can `resources/read` later, return a `ResourceLink(name=..., uri=...)` instead; it is a content block too.
101+
84102
## Icons
85103

86104
An `Icon` is metadata, not content. It doesn't carry the image; it points at one with a URI, and a client may fetch it and show it next to your server's name, a tool, a resource, or a prompt.
@@ -110,6 +128,7 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `
110128

111129
* Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type.
112130
* Build one from a `path=` and let the suffix decide the MIME type, or from in-memory `data=` plus an explicit `format=`.
131+
* Return an `EmbeddedResource` to put a document (text or a base64 blob, with its URI and MIME type) in the result, or a `ResourceLink` to send just the pointer.
113132
* Media results carry no `structured_content` and no output schema.
114133
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
115134
* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.

docs/servers/prompts.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,55 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo
134134
```
135135

136136
!!! info
137-
If you have read **[Tools](tools.md)**, you already know everything on this page. Same decorator, same
137+
If you have read **[Tools](tools.md)**, you already know everything up to this point. Same decorator, same
138138
docstring-as-description, same `Annotated`/`Field`. The only things that change are who
139139
triggers it (the user) and where the result goes (into the conversation).
140140

141+
## More than text
142+
143+
`UserMessage` and `AssistantMessage` also accept a content block, or an `Image` / `Audio` helper, wherever they accept a `str`. Two cases come up in prompts: attaching a document and attaching a picture.
144+
145+
### Embedding a file
146+
147+
```python title="server.py" hl_lines="5 12 21 23"
148+
--8<-- "docs_src/prompts/tutorial004.py"
149+
```
150+
151+
* The style guide is a resource at `style://python` (**[Resources](resources.md)** covers those), read from a `style-guide.md` next to `server.py`. Put any Markdown file there.
152+
* `EmbeddedResource(resource=TextResourceContents(...))`, both from `mcp.types`, carries the file with its URI and MIME type as the first message; the request that refers to it follows as plain text.
153+
* Embedding, rather than pasting the guide into the f-string, lets the client show it as an attachment and reopen `style://python` later, and the model receives the file verbatim. For a binary file use `BlobResourceContents` with a base64 `blob`.
154+
155+
Rendered, the first message's `content` is a `resource` block:
156+
157+
```json
158+
{"type": "resource", "resource": {"uri": "style://python", "mimeType": "text/markdown", "text": "* Prefer early returns.\n..."}}
159+
```
160+
161+
### Attaching an image
162+
163+
```python title="server.py" hl_lines="4 15"
164+
--8<-- "docs_src/prompts/tutorial005.py"
165+
```
166+
167+
* `Image` is the helper from **[Images, audio & icons](media.md)**. `UserMessage` converts it to an `ImageContent` block (the file base64-encoded, MIME type guessed from `.png`) when the prompt renders; `Audio` becomes an `AudioContent` the same way.
168+
* Put any PNG named `architecture.png` beside `server.py`. Prompt arguments are strings, so the picture always comes from the server; `component` only supplies the words.
169+
170+
```json
171+
{"type": "image", "data": "iVBORw0KGgoAAAANSUhEUg...", "mimeType": "image/png"}
172+
```
173+
174+
## Changing the list at runtime
175+
176+
Prompts can be added while clients are connected, for example to let a user save an instruction as a menu entry of their own. Register the prompt, then notify:
177+
178+
```python title="server.py" hl_lines="3 21-23"
179+
--8<-- "docs_src/prompts/tutorial006.py"
180+
```
181+
182+
* `mcp.add_prompt(Prompt.from_function(fn, name=..., description=...))` registers a function exactly as `@mcp.prompt()` would, and `mcp.remove_prompt(name)` is the reverse. `prompts/list` reflects the change immediately.
183+
* `await ctx.notify_prompts_changed()` sends `notifications/prompts/list_changed` to every `2026-07-28` client listening on a `subscriptions/listen` stream (**[Subscriptions](../handlers/subscriptions.md)**). `await ctx.session.send_prompt_list_changed()` sends it to the calling client when that client is pre-2026 (**[Serving legacy clients](../run/legacy-clients.md)**). Call both; each does nothing when there is nobody to tell.
184+
* A client that receives the notification calls `prompts/list` again. In the Python `Client` that is `async with client.listen(prompts_list_changed=True) as sub:`, which yields a `PromptsListChanged` event.
185+
141186
## Recap
142187

143188
* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring.
@@ -146,5 +191,7 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo
146191
* Return a `str` and it becomes one user message. Return a list of `UserMessage` / `AssistantMessage` to seed a multi-turn conversation.
147192
* `title=` and `Field(description=...)` are what a client puts in its UI.
148193
* A missing required argument fails the whole request. There is no per-prompt error result.
194+
* Wrap an `EmbeddedResource` or an `Image` in a `UserMessage` to attach a document or a picture.
195+
* Add or remove prompts at runtime with `mcp.add_prompt(...)` / `mcp.remove_prompt(...)`, then `await ctx.notify_prompts_changed()` and `await ctx.session.send_prompt_list_changed()`.
149196

150197
Server-side autocomplete for a prompt's (or a resource template's) arguments is **[Completions](completions.md)**.

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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from mcp.server import Server, ServerRequestContext
2+
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent, Tool
3+
4+
FIND_BOOK = Tool(
5+
name="find_book",
6+
description="Find one book by ISBN, or by title and author.",
7+
input_schema={
8+
"type": "object",
9+
"properties": {
10+
"isbn": {"type": "string", "pattern": "^[0-9]{13}$"},
11+
"title": {"type": "string"},
12+
"author": {"type": "string"},
13+
},
14+
"oneOf": [{"required": ["isbn"]}, {"required": ["title", "author"]}],
15+
"additionalProperties": False,
16+
},
17+
)
18+
19+
20+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
21+
return ListToolsResult(tools=[FIND_BOOK])
22+
23+
24+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
25+
args = params.arguments or {}
26+
found = f"ISBN {args['isbn']}" if "isbn" in args else f"{args['title']!r} by {args['author']}"
27+
return CallToolResult(content=[TextContent(type="text", text=f"Found {found} on shelf C-3.")])
28+
29+
30+
server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)

docs_src/media/tutorial005.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from mcp.server import MCPServer
2+
from mcp.types import EmbeddedResource, TextResourceContents
3+
4+
mcp = MCPServer("Brand kit")
5+
6+
7+
@mcp.resource("brand://guidelines", mime_type="text/markdown")
8+
def guidelines() -> str:
9+
"""How to use the brand assets."""
10+
return "# Brand guidelines\n\nUse the primary colour for calls to action.\n"
11+
12+
13+
@mcp.tool()
14+
def brand_guidelines() -> EmbeddedResource:
15+
"""The brand guidelines as a Markdown document."""
16+
return EmbeddedResource(
17+
resource=TextResourceContents(uri="brand://guidelines", mime_type="text/markdown", text=guidelines())
18+
)

docs_src/prompts/tutorial004.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from pathlib import Path
2+
3+
from mcp.server import MCPServer
4+
from mcp.server.mcpserver import Message, UserMessage
5+
from mcp.types import EmbeddedResource, TextResourceContents
6+
7+
mcp = MCPServer("Code Helper")
8+
9+
STYLE_GUIDE_FILE = Path(__file__).parent / "style-guide.md" # or the path to your file on disk
10+
11+
12+
@mcp.resource("style://python", mime_type="text/markdown")
13+
def style_guide() -> str:
14+
"""The team's Python style guide."""
15+
return STYLE_GUIDE_FILE.read_text(encoding="utf-8")
16+
17+
18+
@mcp.prompt()
19+
def review_code(code: str) -> list[Message]:
20+
"""Review a piece of code against the team style guide."""
21+
guide = TextResourceContents(uri="style://python", mime_type="text/markdown", text=style_guide())
22+
return [
23+
UserMessage(EmbeddedResource(resource=guide)),
24+
UserMessage(f"Review this code against the style guide above:\n\n{code}"),
25+
]

docs_src/prompts/tutorial005.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pathlib import Path
2+
3+
from mcp.server import MCPServer
4+
from mcp.server.mcpserver import Image, Message, UserMessage
5+
6+
mcp = MCPServer("Code Helper")
7+
8+
DIAGRAM_FILE = Path(__file__).parent / "architecture.png" # or the path to your file on disk
9+
10+
11+
@mcp.prompt()
12+
def explain_component(component: str) -> list[Message]:
13+
"""Explain one component using the architecture diagram."""
14+
return [
15+
UserMessage(Image(path=DIAGRAM_FILE)),
16+
UserMessage(f"Where does {component} sit in this architecture, and what does it talk to?"),
17+
]

docs_src/prompts/tutorial006.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from mcp.server import MCPServer
2+
from mcp.server.mcpserver import Context
3+
from mcp.server.mcpserver.prompts import Prompt
4+
5+
mcp = MCPServer("Code Helper")
6+
7+
8+
@mcp.prompt()
9+
def review_code(code: str) -> str:
10+
"""Review a piece of code."""
11+
return f"Please review this code:\n\n{code}"
12+
13+
14+
@mcp.tool()
15+
async def save_template(name: str, instruction: str, ctx: Context) -> str:
16+
"""Save an instruction as a prompt the user can pick from the menu."""
17+
18+
def template(code: str) -> str:
19+
return f"{instruction}\n\n{code}"
20+
21+
mcp.add_prompt(Prompt.from_function(template, name=name, description=instruction))
22+
await ctx.notify_prompts_changed()
23+
await ctx.session.send_prompt_list_changed()
24+
return f"Saved '{name}' to the prompt menu."

0 commit comments

Comments
 (0)