diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 083e03cd61..205a3ab646 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -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. diff --git a/docs/deprecated.md b/docs/deprecated.md index 9b879f1f6f..71844aa5e2 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -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. diff --git a/docs/servers/media.md b/docs/servers/media.md index 8655b77f12..a79bf86c85 100644 --- a/docs/servers/media.md +++ b/docs/servers/media.md @@ -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. @@ -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. diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index c49860dfd6..1ded9b03d0 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -134,10 +134,55 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo ``` !!! 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. +* 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. @@ -146,5 +191,7 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo * 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)**. diff --git a/docs/servers/tools.md b/docs/servers/tools.md index 5b728cb782..f434389145 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -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. diff --git a/docs_src/lowlevel/tutorial007.py b/docs_src/lowlevel/tutorial007.py new file mode 100644 index 0000000000..4c0a1b32d6 --- /dev/null +++ b/docs_src/lowlevel/tutorial007.py @@ -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) diff --git a/docs_src/media/tutorial005.py b/docs_src/media/tutorial005.py new file mode 100644 index 0000000000..c73acd676e --- /dev/null +++ b/docs_src/media/tutorial005.py @@ -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()) + ) diff --git a/docs_src/prompts/tutorial004.py b/docs_src/prompts/tutorial004.py new file mode 100644 index 0000000000..cfe7b3c986 --- /dev/null +++ b/docs_src/prompts/tutorial004.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from mcp.server import MCPServer +from mcp.server.mcpserver import Message, UserMessage +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}"), + ] diff --git a/docs_src/prompts/tutorial005.py b/docs_src/prompts/tutorial005.py new file mode 100644 index 0000000000..17b2746e45 --- /dev/null +++ b/docs_src/prompts/tutorial005.py @@ -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?"), + ] diff --git a/docs_src/prompts/tutorial006.py b/docs_src/prompts/tutorial006.py new file mode 100644 index 0000000000..c6857da492 --- /dev/null +++ b/docs_src/prompts/tutorial006.py @@ -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)) + await ctx.notify_prompts_changed() + await ctx.session.send_prompt_list_changed() + return f"Saved '{name}' to the prompt menu."