Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-

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)**.

## The dialect is JSON Schema 2020-12

`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:

```python title="server.py" hl_lines="8 14-15"
--8<-- "docs_src/lowlevel/tutorial007.py"
```

* 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.
* 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.

## `_meta`: for the application, not the model

`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.
Expand Down
49 changes: 49 additions & 0 deletions docs/deprecated.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,55 @@ MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SE
send. These two only work end-to-end on a `mode="legacy"` connection whose client
registered the matching callback.

## `ping` on a legacy session

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. From the client:

```python
async def main() -> None:
async with Client("http://localhost:8000/mcp", mode="legacy") as client:
await client.send_ping() # warns; returns an EmptyResult
```

And from the server, inside any handler:

```python
@mcp.tool()
async def check_client(ctx: Context) -> str:
"""A tool that still pings the client mid-call."""
await ctx.session.send_ping() # no warning; an EmptyResult while the client is connected
return "client answered"
```

* `client.send_ping()` warns with `MCPDeprecationWarning` on every call. On a default (`2026-07-28`) connection the server answers `MCPError: Method not found` instead.
* `ctx.session.send_ping()` carries no warning. On a modern connection it raises the same no-back-channel error as any other server-initiated request.
* Neither side registers anything to answer a ping.

## Roots change notifications

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. On the client, passing `list_roots_callback=` (**[Client callbacks](client/callbacks.md)**) is what declares `"roots": {"listChanged": true}`, and one call keeps that promise:

```python
async def open_folder(client: Client, uri: str, name: str) -> None:
"""The user opened another folder: expose it through the roots callback, then tell the server."""
workspace.append(Root(uri=FileUrl(uri), name=name))
await client.send_roots_list_changed()
```

On the server, the low-level `Server` takes the receiving handler:

```python
async def roots_changed(ctx: ServerRequestContext, params: NotificationParams | None) -> None:
"""The client's roots changed: ask for the new list."""
roots = (await ctx.session.list_roots()).roots


server = Server("Bookshop", on_roots_list_changed=roots_changed)
```

* `workspace` is the list your `list_roots_callback` returns. `client.send_roots_list_changed()` warns, and it needs a `mode="legacy"` client: on a modern connection the notification is silently dropped. Keep the session open afterwards, because the server's follow-up `roots/list` arrives on it.
* `MCPServer` has no hook for the notification. On the low-level `Server`, `on_roots_list_changed=` registers the handler (deprecated too, and it warns at construction). The notification carries no payload, so the handler calls `ctx.session.list_roots()` for the new list.

## Silencing the warning

Don't, in new code.
Expand Down
19 changes: 19 additions & 0 deletions docs/servers/media.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ A suffix it doesn't recognise falls back to `application/octet-stream`.
`Audio` from MP3 bytes that way and the client is told `mime_type="audio/wav"`, then
faithfully fails to decode it. When you pass `data=`, pass `format=`.

## Embedding a resource

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.

```python title="server.py" hl_lines="7 14 16-18"
--8<-- "docs_src/media/tutorial005.py"
```

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

```python
result.content # [EmbeddedResource(type="resource", resource=TextResourceContents(uri="brand://guidelines", mime_type="text/markdown", text="# Brand guidelines\n\n..."))]
```

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.

## Icons

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.
Expand Down Expand Up @@ -110,6 +128,7 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `

* Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type.
* Build one from a `path=` and let the suffix decide the MIME type, or from in-memory `data=` plus an explicit `format=`.
* 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.
* Media results carry no `structured_content` and no output schema.
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.
Expand Down
49 changes: 48 additions & 1 deletion docs/servers/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,55 @@
```

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

## More than text

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

### Embedding a file

```python title="server.py" hl_lines="5 12 21 23"
--8<-- "docs_src/prompts/tutorial004.py"
```

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

Rendered, the first message's `content` is a `resource` block:

```json
{"type": "resource", "resource": {"uri": "style://python", "mimeType": "text/markdown", "text": "* Prefer early returns.\n..."}}
```

### Attaching an image

```python title="server.py" hl_lines="4 15"
--8<-- "docs_src/prompts/tutorial005.py"
```

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

```json
{"type": "image", "data": "iVBORw0KGgoAAAANSUhEUg...", "mimeType": "image/png"}
```

## Changing the list at runtime

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:

```python title="server.py" hl_lines="5 23-27"
--8<-- "docs_src/prompts/tutorial006.py"
```

* `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. `add_prompt` keeps an existing entry of the same name rather than overwrite it, so the tool removes any old one first to make saving a replace. `prompts/list` reflects the change immediately.
* `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.

Check notice on line 183 in docs/servers/prompts.md

View check run for this annotation

Claude / Claude Code Review

Pre-existing: new "Changing the list at runtime" section claims ctx.session.send_prompt_list_changed() notifies pre-2026 clients, but MCPServer advertises prompts.listChanged: false in every legacy handshake, so spec-compliant legacy clients ignore the no

Pre-existing: new "Changing the list at runtime" section claims ctx.session.send_prompt_list_changed() notifies pre-2026 clients, but MCPServer advertises prompts.listChanged: false in every legacy handshake, so spec-compliant legacy clients ignore the notification the tutorial teaches

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: new "Changing the list at runtime" section claims ctx.session.send_prompt_list_changed() notifies pre-2026 clients, but MCPServer advertises prompts.listChanged: false in every legacy handshake, so spec-compliant legacy clients ignore the notification the tutorial teaches

Extended reasoning...

docs/servers/prompts.md:183 (and the recap at line 195, backed by docs_src/prompts/tutorial006.py:27) states "await ctx.session.send_prompt_list_changed() sends it to the calling client when that client is pre-2026". The notification is indeed written to the legacy standalone stream, but the server never declared it would send it: every MCPServer path builds the InitializeResult capabilities via create_initialization_options() with a default NotificationOptions() (src/mcp/server/mcpserver/server.py:1024 and :1115, src/mcp/server/runner.py:431 for the streamable-HTTP manager path), and Server.get_capabilities then sets PromptsCapability(list_changed=False) for handshake-era versions (src/mcp/server/lowlevel/server.py:588-595) — notification_options is only honored for modern versions via the subscriptions/listen derivation. The MCP spec makes prompts.listChanged the opt-in that tells a client to expect and handle notifications/prompts/list_changed, so a spec-compliant pre-2026 host that checks the negotiated capability ignores the notification (or never wires up a

Verification: pre-existing — the capability mismatch is real but lives in src/ code this docs-only PR did not touch. New docs/servers/prompts.md:183 teaches "await ctx.session.send_prompt_list_changed() sends it to the calling client when that client is pre-2026" (tutorial006.py:27 calls it), yet every MCPServer legacy handshake advertises prompts.listChanged: false: src/mcp/server/mcpserver/server.py:1024

* 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.

## Recap

* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring.
Expand All @@ -146,5 +191,7 @@
* Return a `str` and it becomes one user message. Return a list of `UserMessage` / `AssistantMessage` to seed a multi-turn conversation.
* `title=` and `Field(description=...)` are what a client puts in its UI.
* A missing required argument fails the whole request. There is no per-prompt error result.
* Wrap an `EmbeddedResource` or an `Image` in a `UserMessage` to attach a document or a picture.
* 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()`.

Server-side autocomplete for a prompt's (or a resource template's) arguments is **[Completions](completions.md)**.
2 changes: 2 additions & 0 deletions docs/servers/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ From those type hints the SDK generates a JSON Schema and sends it to the client

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.)

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)**.

!!! tip
Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`,
the SDK rejects it before your function ever runs.
Expand Down
30 changes: 30 additions & 0 deletions docs_src/lowlevel/tutorial007.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from mcp.server import Server, ServerRequestContext
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent, Tool

FIND_BOOK = Tool(
name="find_book",
description="Find one book by ISBN, or by title and author.",
input_schema={
"type": "object",
"properties": {
"isbn": {"type": "string", "pattern": "^[0-9]{13}$"},
"title": {"type": "string"},
"author": {"type": "string"},
},
"oneOf": [{"required": ["isbn"]}, {"required": ["title", "author"]}],
"additionalProperties": False,
},
)


async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[FIND_BOOK])


async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
args = params.arguments or {}
found = f"ISBN {args['isbn']}" if "isbn" in args else f"{args['title']!r} by {args['author']}"
return CallToolResult(content=[TextContent(type="text", text=f"Found {found} on shelf C-3.")])


server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool)
18 changes: 18 additions & 0 deletions docs_src/media/tutorial005.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from mcp.server import MCPServer
from mcp.types import EmbeddedResource, TextResourceContents

mcp = MCPServer("Brand kit")


@mcp.resource("brand://guidelines", mime_type="text/markdown")
def guidelines() -> str:
"""How to use the brand assets."""
return "# Brand guidelines\n\nUse the primary colour for calls to action.\n"


@mcp.tool()
def brand_guidelines() -> EmbeddedResource:
"""The brand guidelines as a Markdown document."""
return EmbeddedResource(
resource=TextResourceContents(uri="brand://guidelines", mime_type="text/markdown", text=guidelines())
)
25 changes: 25 additions & 0 deletions docs_src/prompts/tutorial004.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pathlib import Path

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] New docs examples/claims added without the chapter behavioral tests every existing tutorial has sweep:docs_src/(media/tutorial005|prompts/tutorial00[4-6]|lowlevel/tutorial007)\.py [also at: docs_src/media/tutorial005.py:1 - [quality] Five new docs_src tutorials (media/tutorial005, prompts/tutorial004-006, lowlevel/tutorial007) and the two new]

Extended reasoning...

Repo convention (tests/docs_src/test_media.py, test_prompts.py, test_lowlevel.py all open with "every claim the page makes, proved against the real SDK" and import/exercise every prior tutorial: media 001-004, prompts 001-003, lowlevel 001-006) is that each docs_src tutorial gets a behavioral test; the five new modules (docs_src/media/tutorial005.py, docs_src/prompts/tutorial004-006.py, docs_src/lowlevel/tutorial007.py) are only covered by the test_shape.py import-only floor, and likewise tests/docs_src/test_deprecated.py's header says each prose claim on docs/deprecated.md is executed, yet the two new sections' claims (legacy ping both directions with the printed '2025-11-25' output, roots list_changed end-to-end via on_roots_list_changed, the notification being 'silently dropped' on modern connections) have no test. Concrete cost: the rendered JSON/output blocks and behavioral assertions on these pages can silently drift from real SDK behavior — exactly the drift these chapter tests exist to prevent — and future refactors won't fail CI when they break the examples' runtime behavi

Verification: nit — Convention is real and violated. tests/docs_src/test_media.py:1, test_prompts.py:1 and test_lowlevel.py:1 all open with "every claim the page makes, proved against the real SDK", and their imports stop just short of the new files: test_media.py:9 from docs_src.media import tutorial001, tutorial002, tutorial003, tutorial004 (no 005), test_prompts.py:9 `from docs_src.prompts import tutoria

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional for this PR: it is scoped to the narrative coverage the tier audit needs and adds no tests. The new docs_src modules are still import-checked by test_shape.py, type-checked by pyright and the fences ruff-linted by test_examples.py; behavioural chapter tests for them can follow separately if we want them.

AI Disclaimer


from mcp.server import MCPServer
from mcp.server.mcpserver import Message, UserMessage

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] nit: New prompt examples import Message/UserMessage from mcp.server.mcpserver, contradicting the same page's prose and every existing docs_src example sweep:from mcp\.server\.mcpserver import .*(UserMessage|Message)

Extended reasoning...

docs/servers/prompts.md line 88 (the bullet under tutorial002, unchanged by this PR) states "UserMessage and AssistantMessage come from mcp.server.mcpserver.prompts.base", and every pre-existing docs_src example imports them from that path (docs_src/prompts/tutorial002.py:2, docs_src/mrtr/tutorial004.py:2). The two new examples rendered on the very same page a few sections later import the same symbols from the top-level re-export instead (docs_src/prompts/tutorial004.py:4 from mcp.server.mcpserver import Message, UserMessage; docs_src/prompts/tutorial005.py:4). Concrete cost: the prompts page now contradicts itself about where these classes live — a reader who follows the earlier bullet and then copies tutorial004 sees two different canonical import paths for the same names on one page, and the docs_src corpus loses its single established spelling. Fix in one sweep: change the two new files to from mcp.server.mcpserver.prompts.base import ... (or, if the top-level spelling is now preferred, update the line-88 prose and the older examples in a follow-up).

Verification: nit — Factually accurate quality finding. docs_src/prompts/tutorial004.py:4 (from mcp.server.mcpserver import Message, UserMessage) and tutorial005.py:4 import from the top-level re-export, while the same rendered page states at docs/servers/prompts.md:88 that "UserMessage and AssistantMessage come from mcp.server.mcpserver.prompts.base", and all pre-existing examples import from that pa

from mcp.types import EmbeddedResource, TextResourceContents

mcp = MCPServer("Code Helper")

STYLE_GUIDE_FILE = Path(__file__).parent / "style-guide.md" # or the path to your file on disk


@mcp.resource("style://python", mime_type="text/markdown")
def style_guide() -> str:
"""The team's Python style guide."""
return STYLE_GUIDE_FILE.read_text(encoding="utf-8")


@mcp.prompt()
def review_code(code: str) -> list[Message]:
"""Review a piece of code against the team style guide."""
guide = TextResourceContents(uri="style://python", mime_type="text/markdown", text=style_guide())
return [
UserMessage(EmbeddedResource(resource=guide)),
UserMessage(f"Review this code against the style guide above:\n\n{code}"),
]
17 changes: 17 additions & 0 deletions docs_src/prompts/tutorial005.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from pathlib import Path

from mcp.server import MCPServer
from mcp.server.mcpserver import Image, Message, UserMessage

mcp = MCPServer("Code Helper")

DIAGRAM_FILE = Path(__file__).parent / "architecture.png" # or the path to your file on disk


@mcp.prompt()
def explain_component(component: str) -> list[Message]:
"""Explain one component using the architecture diagram."""
return [
UserMessage(Image(path=DIAGRAM_FILE)),
UserMessage(f"Where does {component} sit in this architecture, and what does it talk to?"),
]
28 changes: 28 additions & 0 deletions docs_src/prompts/tutorial006.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from contextlib import suppress

from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.prompts import Prompt

mcp = MCPServer("Code Helper")


@mcp.prompt()
def review_code(code: str) -> str:
"""Review a piece of code."""
return f"Please review this code:\n\n{code}"


@mcp.tool()
async def save_template(name: str, instruction: str, ctx: Context) -> str:
"""Save an instruction as a prompt the user can pick from the menu."""

def template(code: str) -> str:
return f"{instruction}\n\n{code}"

with suppress(ValueError): # replace an existing entry of the same name
mcp.remove_prompt(name)
mcp.add_prompt(Prompt.from_function(template, name=name, description=instruction))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
await ctx.notify_prompts_changed()
await ctx.session.send_prompt_list_changed()
return f"Saved '{name}' to the prompt menu."
Comment thread
claude[bot] marked this conversation as resolved.
Loading