Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)

This commit is contained in:
Max 2026-06-26 12:49:19 +02:00 • committed by GitHub
commit 411a6d3980
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 9510 additions and 2639 deletions

View file

@ -0,0 +1,125 @@
# Completions
A client building a UI on top of your server wants to autocomplete argument values as the user types: language names, repository names, file paths.
**Completions** are how your server supplies those suggestions.
## Something worth completing
Completions apply to exactly two things: the arguments of a **prompt** and the parameters of a **resource template**. So start with a server that has one of each:
```python title="server.py" hl_lines="6 12"
--8<-- "docs_src/completions/tutorial001.py"
```
Nothing here is about completions yet.
* `review_code` takes a `language`. A user shouldn't have to guess which spellings you accept.
* `github_repo` takes an `owner` and a `repo`. Free-text boxes for both make a bad form.
## The completion handler
Add **one** function decorated with `@mcp.completion()`:
```python title="server.py" hl_lines="22-30"
--8<-- "docs_src/completions/tutorial002.py"
```
* There is one handler per server. Every completion request lands here, and you branch on what's being completed.
* It must be `async def`: the SDK awaits it.
* It receives three arguments:
* `ref`: *which* prompt or resource template, as a `PromptReference` or a `ResourceTemplateReference`. `isinstance` is how you tell them apart.
* `argument`: `argument.name` is the argument being completed, `argument.value` is what the user has typed so far.
* `context`: the arguments already resolved. Ignore it for now.
* You return a `Completion(values=[...])`, or `None` when you have nothing to offer.
!!! tip
`argument.value` is the prefix the user has typed. The SDK does **not** filter for you: whatever
you put in `values` is what the UI shows. The `startswith` is yours to write.
### Try it
Drive it with the in-memory `Client`, the same one you use in **Testing**. Call
`client.complete()` with `ref=PromptReference(name="review_code")` and
`argument={"name": "language", "value": "py"}`:
```python
result.completion.values # ['python']
```
* `ref` is the same reference type your handler receives.
* `argument` is a plain dict with exactly two keys, `name` and `value`.
Send an empty `value` and you get the whole list back. `lang.startswith("")` is true for every language:
```python
result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript']
```
Ask about `code` (an argument your handler doesn't recognise) and it returns `None`, which the SDK turns into an empty list:
```python
result.completion.values # []
```
`None` means *"no suggestions"*, never an error. A UI falls back to a plain text box.
## A capability you never declared
Registering the handler is the declaration. Connect a client and look:
```python
client.server_capabilities.completions # CompletionsCapability()
```
You didn't list `completions` anywhere. The SDK saw the handler and advertised it during the handshake. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.)
!!! check
Go back to the first `server.py` (the one with no handler) and ask it anyway. The call fails
with a JSON-RPC error:
```text
Method not found
```
And `client.server_capabilities.completions` is `None`. That's the point of the capability: a
well-behaved client checks it and never sends the request you can't answer.
## Dependent arguments
`github://repos/{owner}/{repo}` has two parameters, and the useful values for `repo` depend on which `owner` was picked first.
That's what `context` is for. It carries the arguments the user has **already resolved**:
```python title="server.py" hl_lines="9-12 35-39"
--8<-- "docs_src/completions/tutorial003.py"
```
* The new branch fires for the template's `repo` parameter.
* `context.arguments` is a `dict[str, str] | None` of the values picked so far (here, `owner`).
* No `owner` yet means no sensible suggestions, so the handler returns `None`.
The client sends those resolved values with `context_arguments=`. This time `ref` is a
`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Ask for `repo` with an
empty `value` and pass `context_arguments={"owner": "modelcontextprotocol"}`:
```python
result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector']
```
Drop `context_arguments=` and the same call returns `[]`. The handler can't know which repos to offer until it knows the owner.
!!! info
`Completion` also takes `total=` and `has_more=`. Set them when `values` is a slice of a longer
list, so a UI can show *"and 200 more"*. Most handlers never need them.
## Recap
* Completions are suggestions for **prompt arguments** and **resource template parameters**. Nothing else.
* `@mcp.completion()` registers the one handler. It's `async def (ref, argument, context) -> Completion | None`.
* Branch on `isinstance(ref, ...)` and on `argument.name`. Filter by `argument.value` yourself.
* `None` becomes an empty list. It is never an error.
* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`.
* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`.
Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **Elicitation**.

126
docs/tutorial/context.md Normal file
View file

@ -0,0 +1,126 @@
# The Context
A tool's arguments come from the model. Everything else (the request you are serving, the server you live in, a way to talk back to the client) comes from one object: the **`Context`**.
You don't construct it and you don't configure it. You ask for it.
## Ask for it
Add a parameter annotated with `Context` to any tool:
```python title="server.py" hl_lines="2 8"
--8<-- "docs_src/context/tutorial001.py"
```
* The SDK builds a fresh `Context` for every request and passes it in.
* The parameter **name doesn't matter**. `ctx`, `context`, `c`: the SDK finds it by its annotation.
* Resources and prompts can declare one too, the same way.
* `ctx.request_id` is the id of the request your function is serving right now.
!!! info
If you've used FastAPI, you've seen this move: declare a parameter with the framework's own type
(`Request` there, `Context` here) and the framework supplies it. Nothing to register, nothing to
configure: the type annotation is the whole mechanism.
### Invisible to the model
This is the part to internalise. Here is the input schema `tools/list` reports for `search_books`:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
One property. `ctx` is not an argument: it never appears in the schema, the model is never told about it, and no client can fill it in. It's a contract between you and the SDK, invisible on the wire.
### Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
The form for `search_books` has a single `query` field. Call it with `dune`:
```text
[request 3] Found 3 books matching 'dune'.
```
The number is whichever request this happened to be. Call the tool again and it changes: every request gets its own `Context`.
## What it gives you
The injected object is small. Besides `request_id`:
* `await ctx.read_resource(uri)`: read one of the server's **own** resources from inside a tool. The next section.
* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**.
* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**.
* `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it.
* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **Lifespan**).
Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **Logging** is the short chapter on why.
!!! tip
Injection only happens for the function you registered. A helper that your tool calls doesn't get
its own `Context`; pass `ctx` down as an ordinary argument. There is no ambient
"current context" to fetch from somewhere else.
## Read your own resources
A server's resources aren't only for clients. A tool can read them too:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/context/tutorial002.py"
```
`ctx.read_resource` resolves the URI through the same registry that serves `resources/read`, so a tool gets what a client would get: an iterable of `ReadResourceContents`, one per content block. For this URI there is one:
```python
contents.content # 'fiction, non-fiction, poetry'
contents.mime_type # 'text/plain'
```
* `content` is exactly what `genres()` returned. One source of truth: the client browses the resource, your tools consume it, nobody copies the string.
* `describe_catalog`'s only parameter is the `Context`, so its input schema has **no properties at all**. The model calls it with `{}`.
## Tell the client the list changed
What a server offers is not fixed at import time. Register a tool at runtime, then tell the client:
```python title="server.py" hl_lines="15-16"
--8<-- "docs_src/context/tutorial003.py"
```
* `mcp.add_tool(recommend_book)` registers a plain function as a tool: name, description and schema derived exactly as `@mcp.tool()` would have.
* `await ctx.session.send_tool_list_changed()` sends `notifications/tools/list_changed`. A client that receives it calls `tools/list` again and sees `recommend_book`.
The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource.
!!! check
Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it
anyway and the result is an error the model can read:
```text
Unknown tool: recommend_book
```
Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely
dynamic: `tools/list` reflects whatever is registered *right now*.
## Recap
* Annotate a parameter with `Context` (in a tool, a resource, or a prompt) and the SDK injects it. The name is yours.
* It is invisible to the model: the input schema only ever contains your real arguments.
* `ctx.request_id` identifies the request; `ctx.request_context.lifespan_context` is what your startup yielded.
* `await ctx.read_resource(uri)` lets a tool read the server's own resources.
* `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed.
* Progress reporting and elicitation also start at `Context`; each has its own chapter.
Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**.

View file

@ -0,0 +1,153 @@
# Elicitation
A tool that is halfway through its job and missing one answer doesn't have to fail.
**Elicitation** lets it ask. In the middle of a tool call the server sends the client a question, the client puts it to the user, and the answer comes back into the same function call.
There are two modes:
* **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form.
* **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol.
## Ask with a form
`ctx.elicit()` takes a message and a Pydantic model:
```python title="server.py" hl_lines="9-11 20-23 25"
--8<-- "docs_src/elicitation/tutorial001.py"
```
* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **The Context**.
* `AlternativeDate` is the **schema** of the answer you want.
* The tool is `async def`. It has to be: it stops in the middle and waits for a person.
* On any other date the tool returns straight away. It only asks when it has to.
* The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind.
### What the client receives
The client gets your message and, next to it, a JSON Schema generated from the model:
```json
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean"
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string"
}
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object"
}
```
That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery you already used for a tool's arguments in **Tools**.
!!! warning
An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields
only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`).
Put a model inside the model and `ctx.elicit` raises before anything is sent to the client:
```text
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
```
You are interrupting a person mid-task. If the answer needs nesting, it should have been an
argument to the tool.
### The three answers
`result.action` tells you what the user did, and there are exactly three possibilities:
* `"accept"`: they submitted the form. `result.data` is an `AlternativeDate` instance, already validated.
* `"decline"`: they said no.
* `"cancel"`: they dismissed the question without choosing.
`result.data` only exists on `"accept"`, which is why the example checks `result.action` first. Your type checker enforces the order: after `result.action == "accept"`, `result.data` is an `AlternativeDate`; before it, there is no `.data` at all.
A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally.
!!! tip
The answer is validated against your model before your code sees it. A client that sends
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the
`ValidationError`, your `if` never runs.
## Send the user to a URL
Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere:
```python title="server.py" hl_lines="10-14 23"
--8<-- "docs_src/elicitation/tutorial002.py"
```
* `ctx.elicit_url()` takes the message, the **URL** to visit, and an `elicitation_id` you choose: any string that identifies this elicitation within your server.
* The result has an action and nothing else. `"accept"` means the user agreed to open the URL, **not** that they finished what's on the other side.
* The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP.
Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess.
## The client side
Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`:
```python title="client.py" hl_lines="7-8 19"
--8<-- "docs_src/elicitation/tutorial003.py"
```
* One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch.
* For a URL, you show `params.url` to the user and return the action they chose. Never any `content`.
* For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test.
* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **Client callbacks**.
!!! info
Elicitation is a request from the *server* to the *client*, and those only exist on a
classic-handshake session, which is why this client passes `mode="legacy"`.
On a **2026-07-28** connection a tool asks by *returning* the question from the call
instead; that flow is **Multi-round-trip requests**.
### Try it
Start the form-mode `server.py` (the first one on this page) on Streamable HTTP (**Running your server** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day.
The callback prints the question it was sent:
```text
No tables for 2 on 2025-12-25. Would you like to try another date?
```
It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the tool, which has been waiting inside `await ctx.elicit(...)` this whole time, finishes the booking:
```text
Booked a table for 2 on 2025-12-27.
```
Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit`: the same callback takes the other branch, prints the payment link, and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions.
!!! check
Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day
again. The whole call fails with a protocol error:
```text
Elicitation not supported
```
A client that registered no callback never declared the `elicitation` capability, so there is
nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every
elicitation needs a sensible answer to "what if I can't ask?".
## Recap
* `await ctx.elicit(message, schema=Model)` asks mid-call; your tool resumes with the answer.
* The schema is a flat Pydantic model: primitive fields only, validated on the way back.
* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept.
* `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done.
* The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability.
A tool that can ask is good. A tool that says how far along it is (**Progress**) is next.

View file

@ -0,0 +1,139 @@
# First steps
On the landing page you wrote a server, ran it, and called a tool.
Now do it again, slowly, with all three things a server can expose, and the names for everything you just saw.
## Host, client, and server
Three words you'll see on every page from here on:
* A **host** is the LLM application: Claude, an IDE, an agent runtime. It's the thing the user is talking to.
* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to.
* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly.
You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later in this chapter.
## The three primitives
A server exposes exactly three kinds of thing. What separates them is **who decides to use them**:
| Primitive | Controlled by | What it is | Example |
|---------------|-----------------|-----------------------------------------------------|------------------------------------|
| **Tools** | The model | A function the model calls to take an action | An API call, a database write |
| **Resources** | The application | Data the host loads into the model's context | A file's contents, an API response |
| **Prompts** | The user | A reusable message template the user invokes by name | A slash command, a menu entry |
"Controlled by" is the whole point of the split. A tool runs because the **model** decided to call it. A resource is attached because the **application** decided the model needed it. A prompt runs because the **user** picked it.
!!! info
If you've built a web API you already have most of the intuition: a **resource** is a `GET`
(it loads data and changes nothing) and a **tool** is a `POST` (it does work and may have
side effects). A **prompt** has no HTTP analogue; it's closer to a saved query the user runs
by name.
## One server, all three
```python title="server.py" hl_lines="6 12 18"
--8<-- "docs_src/first_steps/tutorial001.py"
```
Three plain functions, three decorators. Each decorator is the entire registration:
* `@mcp.tool()` makes `add` a **tool**.
* `@mcp.resource("greeting://{name}")` makes `greeting` a **resource template**: the `{name}` in the URI is the function's parameter.
* `@mcp.prompt()` makes `summarize` a **prompt**. The string it returns becomes a user message.
Everything else (the name, the description, the argument schema) the SDK reads from the function itself: its name, its docstring, its type hints. You never declared any of it separately.
!!! tip
The two halves of the SDK have two import paths: `from mcp import Client` and
`from mcp.server import MCPServer`. There is no `from mcp import MCPServer`.
### Try it
Run it with the MCP Inspector:
```console
uv run mcp dev server.py
```
Open the URL it prints. The Inspector has one tab per primitive; walk through them in order.
**Tools.** One entry: `add`, described as *Add two numbers.* The form has a required integer field for `a` and another for `b`. Fill them in, call it, and the result is `3`. The Inspector built that form from `a: int, b: int`. So does every other client.
**Resources.** The *Resources* list is empty. `greeting` is under **Resource Templates**, because `greeting://{name}` has a parameter: there is no single resource to list until someone supplies a `name`. Give it `World` and read it:
```text
Hello, World!
```
**Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages.
The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **Running your server** is the chapter for that.
## Capabilities
You saw three tabs in the Inspector. How did it know there were three?
When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you.
Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port):
```python
import asyncio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as client:
print(client.server_capabilities.model_dump(exclude_none=True))
asyncio.run(main())
```
```text
{'prompts': {'list_changed': False}, 'resources': {'subscribe': False, 'list_changed': False}, 'tools': {'list_changed': False}}
```
That dictionary is the server's half of the handshake:
| Capability | The client may now call |
|-------------|------------------------------------------------------------|
| `tools` | `tools/list`, `tools/call` |
| `resources` | `resources/list`, `resources/templates/list`, `resources/read` |
| `prompts` | `prompts/list`, `prompts/get` |
`MCPServer` serves all three primitives, so all three are always declared.
Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **Completions** proves it.
!!! info
`Client(mcp)` is the same in-memory client every example in this tutorial is tested with, and
it's how you'll test yours. It gets a whole chapter: **Testing**.
## What you did not write
Look back over this page. You wrote three small Python functions. You did **not** write:
* A JSON Schema. `a: int, b: int` *is* the schema for `add`.
* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you.
* A capability declaration. `MCPServer` made it for you.
* A line of protocol. The handshake, the version negotiation, the JSON-RPC framing: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it.
That ratio is the whole point of the SDK.
## Recap
* A **host** is the LLM app, a **client** is its MCP-speaking half, a **server** is what you build.
* Tools are **model**-controlled, resources are **application**-controlled, prompts are **user**-controlled.
* One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function.
* A URI with a `{param}` makes a resource **template**, listed separately from concrete resources.
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
Each primitive now gets its own chapter, starting with the one the model drives: **Tools**.

View file

@ -0,0 +1,132 @@
# Handling errors
A tool can fail in two ways, and the SDK treats them very differently.
Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it.
This chapter is about choosing.
## An error the model can fix
Take a tool that looks something up, and let the lookup miss:
```python title="server.py" hl_lines="11-12"
--8<-- "docs_src/handling_errors/tutorial001.py"
```
There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would.
Call it with a title that isn't in the catalog and look at the result:
```python
result.is_error # True
result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")]
result.structured_content # None
```
* The request **succeeded**. There is a result; nothing was raised at the caller.
* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads.
* `structured_content` is `None`. A failed call has no return value to structure.
This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want.
The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent.
!!! tip
Never `return` an error message from a tool. A returned string has `is_error=False`, so to the
model (and to every client UI) it looks like the tool worked and that string was the answer.
`raise`. The flag is the signal.
## An error the model cannot fix
Now swap `ValueError` for `MCPError`.
```python title="server.py" hl_lines="1 3 15"
--8<-- "docs_src/handling_errors/tutorial002.py"
```
`MCPError` is the SDK's **protocol error**. It is the one exception the tool wrapper does *not* catch: it propagates, and the whole `tools/call` request fails with a JSON-RPC error instead of a result.
```json
{
"code": -32602,
"message": "No book titled 'Nothing' in the catalog."
}
```
* There is **no result**. No `content`, no `is_error`: nothing for the model to read.
* The **host** application gets the error instead, the same way it would if the tool didn't exist at all.
* `code`, `message`, and `data` arrive intact. `INVALID_PARAMS` is `-32602`; `mcp_types` exports it and the other JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) as constants so you never type a magic number.
!!! check
Same lookup, same miss, but now the call *raises* on the client side instead of returning:
```text
mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog.
```
The first version handed the model a sentence it could react to. This one hands it nothing.
For `get_author` that is strictly worse, which is the point of the next section.
## Which one to raise
The two paths answer two different questions.
* **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors.
* **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message.
One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`.
By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it.
!!! info
`MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional
`data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised
`MCPError` verbatim instead of sanitising it.
## A resource that doesn't exist
Resources draw the same line, and ship one named exception for the common case.
```python title="server.py" hl_lines="2 13"
--8<-- "docs_src/handling_errors/tutorial003.py"
```
`books://{title}` is a **template**. It matches *any* title, so "the URI is well-formed" and "the book exists" are two different questions, and only your function can answer the second one.
When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol error the spec assigns to a missing resource: `-32602` with the requested URI in `data`, so the client knows *which* read failed.
```json
{
"code": -32602,
"message": "No book titled 'Nothing' in the catalog.",
"data": {"uri": "books://Nothing"}
}
```
Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **Resources**.
## Errors you never raise
A bad argument never reaches your function.
Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **Tools** with `Field(le=50)`.
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.
!!! info
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error
back into a traceback: by the time that flag could act, your exception is already the
`is_error=True` result. Assert on the result. **Testing** covers the pattern.
## Recap
* Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default.
* Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact.
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
* `from mcp import MCPError`; the error-code constants come from `mcp_types`.
Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **Lifespan**.

51
docs/tutorial/index.md Normal file
View file

@ -0,0 +1,51 @@
# Tutorial - User Guide
This tutorial shows you how to use the MCP Python SDK, step by step.
Each section gradually builds on the previous ones, but it's written so you can go straight to any specific section to solve a specific problem. It also works as a future reference: you can come back to exactly the part you need.
## Run the code
All the code blocks can be copied and used directly: they are complete, working files.
To follow along, paste a block into a `server.py` and open it in the MCP Inspector:
```console
uv run mcp dev server.py
```
It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything.
## You will not be guessing
Every example in this tutorial is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**:
```python
import pytest
from mcp import Client
from server import mcp
@pytest.mark.anyio
async def test_add() -> None:
async with Client(mcp) as client:
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.structured_content == {"result": 3}
```
No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly.
If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs.
You'll use this yourself in the [Testing](testing.md) chapter; it's how you test your own servers, too.
## Install the SDK
If you haven't yet, [install the SDK](../installation.md) first.
## Advanced User Guide
There is also an **Advanced User Guide** you can read after this one.
It builds on this tutorial, uses the same concepts, and teaches you the extra things: the low-level `Server`, middleware, authorization, the 2026-07-28 protocol negotiation. But you should read this first: everything in the Advanced guide assumes you know the basics.

102
docs/tutorial/lifespan.md Normal file
View file

@ -0,0 +1,102 @@
# Lifespan
Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model.
You don't want to build it on every call, and you do want to close it cleanly. That's what the **lifespan** is for.
## A typed lifespan
A lifespan is an `@asynccontextmanager` that receives the server and `yield`s **one object**. Whatever you yield is available to every handler for as long as the server runs.
```python title="server.py" hl_lines="25-31 34 38 40"
--8<-- "docs_src/lifespan/tutorial001.py"
```
Read it bottom-up:
* `app_lifespan` connects the `Database` **before** the `yield` and disconnects it **after**, in a `finally`. That's startup and shutdown.
* It yields an `AppContext`, a plain dataclass holding the things you set up. One field today, ten tomorrow.
* `MCPServer("Bookshop", lifespan=app_lifespan)` is the whole wiring.
* Inside the tool, the yielded object is `ctx.request_context.lifespan_context`.
The lifespan runs **once**. It is entered when the server starts (before the first request) and exited when the server stops. Every request in between shares the same `AppContext`.
!!! info
If you've written a FastAPI `lifespan`, you already know this. Same decorator, same `yield`, same `finally`.
### What the model sees
Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it never reaches the input schema:
```json
{
"type": "object",
"properties": {
"genre": {"title": "Genre", "type": "string"}
},
"required": ["genre"],
"title": "count_booksArguments"
}
```
`genre` is the only argument the model can pass. The lifespan is your server's business.
`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **The Context**.
### It really is typed
Look at the annotation again: `ctx: Context[AppContext]`.
That one type parameter is why `ctx.request_context.lifespan_context` **is** an `AppContext` to your type checker. `.db` autocompletes; `.dbb` is an error before you ever run the server.
Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help.
!!! warning
`Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or
`@mcp.prompt()` function and every call to that handler fails. The client gets an error back,
and the server log shows why:
```text
Context is not available outside of a request
```
In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is
still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not
the object.
!!! tip
There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`,
so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a
bare `Context` types it as `dict[str, Any]`.
## Watch it happen
"Startup runs before the first request" is the kind of sentence you should not have to take on faith.
Strip the server down to the lifecycle: give `Database` a `connected` flag, flip it in `connect()` and `disconnect()`, and add a tool that reports it.
```python title="server.py" hl_lines="11 14 17 25 44"
--8<-- "docs_src/lifespan/tutorial002.py"
```
`database` lives at module level for one reason: so you can look at it from *outside* the server.
!!! check
Three moments, three values:
* Before the server starts, `database.connected` is `False`. Importing the module connected nothing.
* While it's running, call `database_status` and the result is `"connected"`.
* Stop the server and the `finally` block runs: `database.connected` is `False` again.
The work happened exactly where you put it: around the `yield`, not at import time and not per request.
## Recap
* `lifespan=` takes an `@asynccontextmanager` that receives the server and `yield`s one object.
* Code before the `yield` is startup. The `finally` after it is shutdown.
* It runs once, around the whole life of the server, not per request.
* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt.
* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`.
* No `lifespan=` means an empty `dict`, never `None`.
Next: tools that return more than text, **Media**.

78
docs/tutorial/logging.md Normal file
View file

@ -0,0 +1,78 @@
# Logging
Log from a tool the way you log from any other Python function: with the standard library.
MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **Deprecated features**.
What you do instead is what you do in every other Python program: the standard library.
## A tool that logs
```python title="server.py" hl_lines="1 5 13"
--8<-- "docs_src/logging/tutorial001.py"
```
* `logging.getLogger(__name__)` gives you a logger named after your module. Create it once, at the top.
* Inside the tool you call `logger.info(...)` like in any other function. Nothing to inject, nothing to `await`, nothing MCP-specific.
!!! check
Call the tool and look at the whole result:
```python
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
result.structured_content # {'result': "Found 3 books matching 'dune'."}
```
The log line is nowhere in it. Logging is for **you**, the person operating the server. The model
never sees it. If the model should read something, `return` it.
## Where it goes
For a **stdio** server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its **stdout**. Standard error is yours.
The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean.
!!! tip
Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
line and the client is trying to parse it as JSON-RPC.
`logger.debug("got here")` is the same one line of effort and goes to the right place.
## The level
You don't have to call `logging.basicConfig()` yourself. Constructing an `MCPServer` already did, with a handler pointed at standard error, at the level you pass as `log_level=`, so `MCPServer("Bookshop", log_level="DEBUG")` is all it takes to see your `logger.debug(...)` lines.
The default is `"INFO"`.
`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
## Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
Call `search_books` from the **Tools** tab. The Inspector shows you the result: only the return value. The line
```text
Searching for 'dune'
```
went to standard error: the terminal, not the wire.
!!! info
If what you actually want is *tracing* (every request, how long it took, whether it failed), you
don't want log lines, you want spans. The SDK ships an `OpenTelemetryMiddleware` for exactly that.
See **Middleware**.
## Recap
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
* Log output never reaches the model. Only the value you `return` does.
* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server.
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **Testing**.

108
docs/tutorial/media.md Normal file
View file

@ -0,0 +1,108 @@
# Media
Text is not the only thing a tool can return.
The SDK ships two helpers for binary results (**`Image`** and **`Audio`**) and an **`Icon`** type for giving your server, tools, resources, and prompts a face in the client's UI.
## Returning an image
Annotate the return type as `Image` and return one:
```python title="server.py" hl_lines="14 16"
--8<-- "docs_src/media/tutorial001.py"
```
* `Image` takes exactly one of `data` (raw bytes) or `path` (a file to read).
* `format="png"` becomes the MIME type the client sees: `image/png`.
* The bytes here are a one-pixel placeholder so the file runs on its own. In a real server they come from Pillow, matplotlib, a headless browser, or anything else that hands you `bytes`.
`Image` is an SDK convenience, not a protocol type. On the wire your return value becomes an **`ImageContent`** block (your bytes base64-encoded, plus the MIME type):
```python
result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")]
result.structured_content # None
```
Two things to notice:
* `data` is base64. You returned raw `bytes`; the SDK did the encoding.
* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **Structured Output**, where the return annotation *is* the schema.)
!!! info
`ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent`
you met in **Tools**. A tool result is a list of content blocks; `Image` and `Audio` are
the shortest way to produce the two binary kinds.
### Try it
```console
uv run mcp dev server.py
```
Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders it as a picture. You returned `bytes`; everything between that and the pixels on screen was the SDK.
## Returning audio
`Audio` is the same shape:
```python title="server.py" hl_lines="21-24"
--8<-- "docs_src/media/tutorial002.py"
```
The result is an **`AudioContent`** block:
```python
result.content # [AudioContent(type="audio", data="UklGRjQAAABXQVZFZm1...", mime_type="audio/wav")]
result.structured_content # None
```
Same deal: raw bytes in, base64 and a MIME type out, no output schema.
## Bytes or a file
Both helpers also accept `path=` instead of `data=`. The file is read when the result is built, and the MIME type is guessed from the suffix:
* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`.
* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`.
A suffix it doesn't recognise falls back to `application/octet-stream`.
!!! check
With `data=` there is no filename, so there is nothing to guess from. Forget `format=` and
the SDK falls back to a default: `image/png` for images, `audio/wav` for audio. Build an
`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=`.
## 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.
```python title="server.py" hl_lines="5-6 8 11 17"
--8<-- "docs_src/media/tutorial003.py"
```
* `src` is a URI the client can resolve: `https:`, or a `data:` URI if you want the icon embedded with no extra fetch.
* `mime_type` and `sizes` (`"48x48"`, or `"any"` for a scalable format) let the client pick the right one when you offer several.
* `theme="light"` or `theme="dark"` marks an icon for one colour scheme.
The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`.
### Where a client sees them
Icons travel with whatever they decorate. The server's arrive during the handshake, on `client.server_info`:
```python
client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])]
```
A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `Resource` from `resources/list`, a prompt's on the `Prompt` from `prompts/list`. The field is always called `icons`.
## Recap
* 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 in-memory `data=` plus an explicit `format=`, or from a `path=` and let the suffix decide.
* 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.
That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **Completions**.

117
docs/tutorial/progress.md Normal file
View file

@ -0,0 +1,117 @@
# Progress
A tool that takes thirty seconds and says nothing for thirty seconds looks broken.
**Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line.
## Report it from the tool
Take a **`Context`** parameter and call `report_progress`:
```python title="server.py" hl_lines="8 11"
--8<-- "docs_src/progress/tutorial001.py"
```
Three arguments, and you decide what they mean:
* `progress`: how far you are. The spec requires it to **increase** with every report; never repeat a value or go backwards.
* `total`: how much there is in total, if you know. Optional.
* `message`: one human-readable line about *this* step. Optional.
`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **The Context** chapter is all about that object; progress is one of the things it gives you.
## Listen for it from the client
The client opts in **per call**, by passing `progress_callback=` to `call_tool`:
```python title="client.py" hl_lines="7 16"
import anyio
from mcp import Client
from server import mcp
async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")
async def main() -> None:
async with Client(mcp) as client:
result = await client.call_tool(
"import_catalog",
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
progress_callback=show,
)
print(result.structured_content)
anyio.run(main)
```
The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`.
!!! info
`Client(mcp)` connects straight to the server object, in memory, the same client the **Testing**
chapter is built on. `progress_callback` is the same parameter whatever transport the `Client`
uses; the *timing* you are about to see is the in-memory connection's. It runs your callback
inline, so every report lands before `call_tool` returns. Over a real transport the
notifications race the result, and a slow callback can still be running after `call_tool` has
returned.
### Try it
Put `client.py` next to `server.py` and run it:
```console
python client.py
```
```text
Imported https://example.com/a.json (1/2)
Imported https://example.com/b.json (2/2)
{'result': 'Imported 2 records.'}
```
Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working.
!!! warning
`progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument
for it, because different calls want different callbacks: one drives a download bar, the next
one a log line.
!!! check
Now delete `progress_callback=show` and run it again:
```text
{'result': 'Imported 2 records.'}
```
No error, no warning, same result. `report_progress` is a **no-op when the caller didn't ask
for progress**, so you report unconditionally and never have to wonder whether anyone is
listening.
## When you don't know the total
`total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header.
Leave it out:
```python title="server.py" hl_lines="20"
--8<-- "docs_src/progress/tutorial002.py"
```
The callback receives `total=None`. A client can still show *activity* ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar.
!!! tip
`progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the
user would recognise, and only promise a `total` you can keep.
## Recap
* `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`.
* The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`.
* The callback is `async (progress, total, message) -> None` and fires while the tool is still running.
* No callback on the call means `report_progress` does nothing. Report unconditionally.
* Omit `total` when you don't know it; the callback gets `None`.
Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **Logging** is next.

150
docs/tutorial/prompts.md Normal file
View file

@ -0,0 +1,150 @@
# Prompts
A **prompt** is a message template the user picks.
Tools are for the model. A prompt is the opposite: the user chooses one from a menu in their client (a slash command, a button), fills in its arguments, and the rendered messages go into the conversation as if they had typed them.
You declare one by putting `@mcp.prompt()` on a function that returns the text.
## Your first prompt
```python title="server.py" hl_lines="6-9"
--8<-- "docs_src/prompts/tutorial001.py"
```
The SDK reads the same three things it read from your tools:
* The **name** is the function name: `review_code`.
* The **description** the client shows is the docstring: `Review a piece of code.`
* The **arguments** come from the parameters. `code` has no default, so it's required.
That is what a client gets back from `prompts/list`:
```json
{
"name": "review_code",
"description": "Review a piece of code.",
"arguments": [
{"name": "code", "required": true}
]
}
```
There is no JSON Schema here. Prompt arguments are a flat list of **named string values**: a form a person fills in, not a payload a model constructs.
### Rendering it
The client renders the template with `prompts/get`, passing the arguments. Your function runs and the `str` you return becomes **one user message**:
```json
{
"description": "Review a piece of code.",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Please review this code:\n\ndef add(a, b): return a + b"
}
}
],
"resultType": "complete"
}
```
That is the entire life of a prompt: listed by name, rendered on demand, dropped into the chat.
!!! check
`required` is enforced before your function runs. Render `review_code` without `code` and the
request itself fails with a JSON-RPC error (code `-32603`):
```text
mcp.shared.exceptions.MCPError: Internal server error
```
There is no tool-style error result to hand back to a model, because no model is in the loop:
the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log.
### Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
Open the **Prompts** tab and select `review_code`. The Inspector draws a form with one required `code` field. Fill it in, render it, and you get back exactly the user message above.
## More than one message
A code review is one message. A debugging session is a conversation, and a prompt can seed the whole thing.
Return a list of messages instead of a `str`:
```python title="server.py" hl_lines="2 13-20"
--8<-- "docs_src/prompts/tutorial002.py"
```
* `UserMessage` and `AssistantMessage` come from `mcp.server.mcpserver.prompts.base`. Hand them a `str` and they wrap it in `TextContent` for you. The role is the class name.
* `Message` is their common base. Use it as the return annotation.
Rendering `debug_error` now produces three messages, in order:
```json
{
"description": "Start a debugging conversation.",
"messages": [
{"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}},
{"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}},
{
"role": "assistant",
"content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"}
}
],
"resultType": "complete"
}
```
Notice the last one. Pre-filling an `assistant` turn is how you steer the model's *next* reply without making the user type the steering themselves.
## Titles and argument descriptions
`review_code` is a function name, not a label. Give the client something better to put on the button, and describe each argument so the form explains itself:
```python title="server.py" hl_lines="10-13"
--8<-- "docs_src/prompts/tutorial003.py"
```
* `title="Code review"` is the human-readable name, exactly like a tool's `title`.
* `Annotated[str, Field(description=...)]` is the same pattern you used in **Tools**. Here the description lands on the argument instead of in a schema.
* `language` has a default, so it stops being required.
The `prompts/list` entry now carries everything a client needs to draw a good form:
```json
{
"name": "review_code",
"title": "Code review",
"description": "Review a piece of code.",
"arguments": [
{"name": "code", "description": "The code to review.", "required": true},
{"name": "language", "description": "The language the code is written in.", "required": false}
]
}
```
!!! info
If you have read **Tools**, you already know everything on this page. 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).
## Recap
* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring.
* Prompts are **user-controlled**: the client lists them, the user picks one and fills in the arguments.
* Arguments are a flat list of named strings (no schema). A parameter with a default is optional.
* 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.
Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **The Context**.

139
docs/tutorial/resources.md Normal file
View file

@ -0,0 +1,139 @@
# Resources
A **resource** is data you expose for the application to read.
That's the split. A tool is something the **model** decides to call. A resource is something the **application** decides to load (a config file, a record, a document) and put in front of the model as context.
You declare one by putting `@mcp.resource(uri)` on a plain Python function.
## Your first resource
```python title="server.py" hl_lines="6-8"
--8<-- "docs_src/resources/tutorial001.py"
```
It's the same shape as a tool, plus one thing: the **URI**. Resources are addressed, not named. A client asks for `config://app`, never for `get_config`.
The SDK still reads the rest from the function:
* The **name** is the function name: `get_config`.
* The **description** the client sees is the docstring.
* The **content** is whatever you return.
During `resources/list` the client gets this:
```json
{
"name": "get_config",
"uri": "config://app",
"description": "The active shop configuration.",
"mimeType": "text/plain"
}
```
And when it reads `config://app`, your function runs and the return value comes back as text:
```python
result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")]
```
!!! tip
Listing is cheap. Your function is **not** called during `resources/list`, only during
`resources/read`, and only for the URI that was asked for. Expose a thousand resources
and you pay for the ones somebody opens.
### Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
Open the URL it prints and go to the **Resources** tab. `config://app` is in the list with its description. Click it and the Inspector reads it: there are your two lines of config.
## Resource templates
One URI per record doesn't scale. Put a **placeholder** in the URI and a matching parameter on the function:
```python title="server.py" hl_lines="12-13"
--8<-- "docs_src/resources/tutorial002.py"
```
`{user_id}` in the URI, `user_id: str` on the function. That is the entire contract.
This is now a **resource template**, and it moves house: it leaves `resources/list` and shows up in `resources/templates/list` instead, as a pattern rather than an address:
```json
{
"name": "get_user_profile",
"uriTemplate": "users://{user_id}/profile",
"description": "A customer's profile.",
"mimeType": "text/plain"
}
```
The client fills in the placeholder and reads a concrete URI: `users://42/profile`, `users://ada/profile`. One function answers all of them, with the matched value passed in as `user_id`:
```python
result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")]
```
Notice the `uri` in the result. It is the **concrete** URI the client asked for, not the template.
!!! check
The placeholders and the parameters have to agree. Rename the function parameter to
`user` while the URI still says `{user_id}` and the decorator refuses **at import time**,
before any client gets near it:
```text
ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'}
```
A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one.
`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **The Context** chapter covers what it gives you.
## What you return
You're not limited to `str`. Give each resource a `mime_type` and return whatever fits:
```python title="server.py" hl_lines="8-9 14-15 20-21"
--8<-- "docs_src/resources/tutorial003.py"
```
* `readme` returns a `str`, so it's sent as-is. This is the common case.
* `catalog_stats` returns a `dict`, so the SDK serialises it to **JSON text** for you:
```json
{
"books": 1204,
"authors": 391
}
```
* `placeholder_cover` returns `bytes`, so the client gets a `BlobResourceContents` instead of a `TextResourceContents`, with your bytes base64-encoded in its `blob` field.
The same rule applies to anything else JSON-serialisable: a list, a Pydantic model, a dataclass. If it isn't a `str` and isn't `bytes`, it becomes JSON.
`mime_type` is yours to declare, and it defaults to `text/plain`. The SDK never inspects what you return to guess it, so a `dict` resource you don't label is still advertised as plain text.
!!! tip
`name=`, `title=` and `description=` are also accepted by `@mcp.resource()` when you don't
want to derive them from the function. And when there's no function to write at all,
`mcp.server.mcpserver.resources` has ready-made `Resource` classes (`TextResource`,
`BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register
with `mcp.add_resource(...)`.
A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **The Client**.
## Recap
* `@mcp.resource(uri)` on a function makes it a resource. The URI is the address, the return value is the content, the docstring is the description.
* A `{placeholder}` in the URI makes it a **template**: it's listed under `resources/templates/list` and one function serves every URI that matches.
* Placeholder names must equal the function's parameter names. Get it wrong and you find out at import time, not in production.
* Your function runs when the resource is **read**, not when it's listed.
* `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it.
* Tools are for the model to act. Resources are for the application to read.
Next: the third primitive, the one a person picks from a menu, **Prompts**.

View file

@ -0,0 +1,245 @@
# Structured Output
In **Tools** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`.
This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest.
The short version: **the return type annotation is the output schema**. You already wrote it.
## The output schema
```python title="server.py" hl_lines="9"
--8<-- "docs_src/structured_output/tutorial001.py"
```
The line that matters is the signature: `-> int`.
Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **Tools**:
```json
{
"properties": {
"result": {"title": "Result", "type": "integer"}
},
"required": ["result"],
"title": "get_temperatureOutput",
"type": "object"
}
```
A bare `int` isn't a JSON object, so the SDK **wraps** it in `{"result": ...}`. Call the tool and both channels are filled:
```python
result.content # [TextContent(text="17")]
result.structured_content # {"result": 17}
```
Every scalar gets the same wrapper: `str`, `int`, `float`, `bool`, `bytes`, `None`.
## Two channels
Why send the same value twice?
* `content` is for the **model**. A language model reads text; this is the only part of the result it sees.
* `structured_content` is for the **application** the model runs inside: code that wants `17`, not a sentence containing "17".
* `output_schema` is the contract between them, published before the tool is ever called.
You return one Python value. The SDK fills in all three.
## Return a model
Declare the shape as a Pydantic `BaseModel` and return an instance:
```python title="server.py" hl_lines="8-11 15"
--8<-- "docs_src/structured_output/tutorial002.py"
```
`WeatherData` **is** the schema now. No wrapper, no `result` key:
```json
{
"properties": {
"temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"},
"humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"}
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object"
}
```
`structured_content` is the object, field for field:
```python
result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
```
And the model is not left out. The SDK serializes the same object to JSON text for `content`:
```json
{
"temperature": 16.2,
"humidity": 0.83,
"conditions": "Overcast"
}
```
Notice the `Field(description=...)` on `temperature` and `humidity` landed in the schema. The same `Field` that described your **inputs** describes your outputs.
!!! info
If you've used FastAPI's `response_model`, you already know this: a Pydantic model as the declared
response, serialized and documented for you. The only difference is that here the return annotation
is the whole declaration.
## A `TypedDict`
Not every shape deserves a class. A `TypedDict` produces the same schema:
```python title="server.py" hl_lines="8"
--8<-- "docs_src/structured_output/tutorial003.py"
```
A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for).
## A dataclass
Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes.
```python title="server.py" hl_lines="8-9"
--8<-- "docs_src/structured_output/tutorial004.py"
```
Three spellings, one schema. Use whichever your codebase already has.
## Lists
A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapper, with your item type as a `$defs` reference inside it:
```python title="server.py" hl_lines="15"
--8<-- "docs_src/structured_output/tutorial005.py"
```
```json
{
"$defs": {
"WeatherData": {
"properties": {
"temperature": {"title": "Temperature", "type": "number"},
"humidity": {"title": "Humidity", "type": "number"},
"conditions": {"title": "Conditions", "type": "string"}
},
"required": ["temperature", "humidity", "conditions"],
"title": "WeatherData",
"type": "object"
}
},
"properties": {
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"}
},
"required": ["result"],
"title": "get_forecastOutput",
"type": "object"
}
```
Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}]}`. `content` becomes **two** `TextContent` blocks, one per item: a list is flattened for the model rather than dumped as one string.
`tuple[...]`, unions, and `Optional[...]` are wrapped the same way.
## Dictionaries
`dict[str, ...]` is the one generic that already *is* a JSON object, so it isn't wrapped:
```python title="server.py" hl_lines="9"
--8<-- "docs_src/structured_output/tutorial006.py"
```
```json
{
"additionalProperties": {"type": "number"},
"title": "get_temperaturesDictOutput",
"type": "object"
}
```
```python
result.structured_content # {"London": 16.2, "Reykjavik": 4.4}
```
The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper.
## Validation
`output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server.
You don't notice while you build the value by hand: Pydantic already made sure your `WeatherData` was a `WeatherData`. You notice the day the data comes from somewhere you don't control:
```python title="server.py" hl_lines="9 21"
--8<-- "docs_src/structured_output/tutorial007.py"
```
The annotation promises `WeatherData`. The upstream response stopped sending `humidity`.
!!! check
Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails,
and the first lines of the error name the field:
```text
Error executing tool get_weather: 1 validation error for WeatherData
humidity
Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict]
```
That text comes back as the tool result with `is_error=True`, so the model knows the call failed
instead of confidently reading weather that isn't there.
Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type.
## Opting out
Sometimes the return annotation is for your type checker, not for the protocol. Pass `structured_output=False` and the tool is text-only:
```python title="server.py" hl_lines="6"
--8<-- "docs_src/structured_output/tutorial008.py"
```
No `output_schema`, no wrapping, no validation. `structured_content` is `None` and `content` is the string you returned.
The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text.
## A class without type hints
There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**.
```python title="server.py" hl_lines="6-9"
--8<-- "docs_src/structured_output/tutorial009.py"
```
`Station` sets `name` and `online` inside `__init__`, but the *class* declares nothing. The SDK reads class annotations, finds none, and gives up.
!!! warning
It gives up **silently**. `output_schema` is `None`, `structured_content` is `None`, and the text
the model reads is the object's `repr`:
```text
"<server.Station object at 0x7f539d75b230>"
```
No error, no warning, a useless tool. Move the annotations onto the class body, or pass
`structured_output=True`, which turns this into a hard error the moment the module imports:
`Function get_station: return type <class 'server.Station'> is not serializable for structured output`.
!!! tip
Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the
application can see but the model can't)? That's **The low-level Server**.
## Recap
* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`.
* Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are.
* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application).
* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result.
* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it.
You now own everything a tool can say back. Next, the second primitive: **Resources**.

106
docs/tutorial/testing.md Normal file
View file

@ -0,0 +1,106 @@
# Testing
The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly.
No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`.
## Basic usage
Let's assume you have a simple server with a single tool:
```python title="server.py"
--8<-- "docs_src/testing/tutorial001.py"
```
To run the test below you'll need two extra (development) dependencies:
=== "uv"
```bash
uv add --dev pytest inline-snapshot
```
=== "pip"
```bash
pip install pytest inline-snapshot
```
!!! info
These docs assume you already know [`pytest`](https://docs.pytest.org/en/stable/).
[`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is what the test below
uses to assert on the whole result object in one line. It records the output of a test as the
`snapshot(...)` literal you see. If you'd rather not use it, drop the import and assert on the
fields you care about (`result.content[0].text == "3"`) like in any other test.
Now the test:
```python title="test_server.py"
import pytest
from inline_snapshot import snapshot
from mcp import Client
from mcp_types import CallToolResult, TextContent
from server import mcp
@pytest.fixture
def anyio_backend(): # (1)!
return "asyncio"
@pytest.fixture
async def client(): # (2)!
async with Client(mcp, raise_exceptions=True) as c:
yield c
@pytest.mark.anyio
async def test_call_add_tool(client: Client):
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result == snapshot(
CallToolResult(
content=[TextContent(type="text", text="3")],
structured_content={"result": 3},
)
)
```
1. If you are using `trio`, return `"trio"` instead. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for the details.
2. The fixture yields a connected client. Every test that takes `client` gets a fresh in-memory connection to the same server.
There you go! You can now extend your tests to cover more scenarios.
## Why `raise_exceptions=True`?
Two different things can go wrong, and this flag only touches one of them.
An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with
`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or
without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it:
**Handling errors**.
A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the
server sanitises it into a generic `"Internal server error"` before the client sees it. You should
never leak the details of an unexpected crash to a remote caller. In a test that is exactly what
you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message
instead of the sanitised one.
Leave it on in tests. It has no meaning in production code.
## In-process by default
!!! note
`Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and
picks the appropriate protocol path. Pin `mode="legacy"` if your test exercises legacy-specific
semantics (sampling or elicitation push, `message_handler`), and drop `raise_exceptions=True`
there: a legacy connection never sanitises in the first place, and the flag re-raises the
failure inside the server task instead of in your test.
That one line is also why the rest of this tutorial can promise you that its examples work: every
example file is exercised by the SDK's own test suite through exactly this client. You're using the
same tool the SDK uses on itself.
The tutorial ends here. Putting your tested server in front of a real client, over a real
transport, is **Running your server**.

172
docs/tutorial/tools.md Normal file
View file

@ -0,0 +1,172 @@
# Tools
A **tool** is a function the model can call.
You declare one by putting `@mcp.tool()` on a plain Python function. That's the whole API.
## Your first tool
```python title="server.py" hl_lines="6-8"
--8<-- "docs_src/tools/tutorial001.py"
```
Look at what you wrote. There are no schemas, no JSON, no protocol, just a function. The SDK reads three things from it:
* The **name** of the tool is the name of the function: `search_books`.
* The **description** the model sees is the docstring: `Search the catalog by title or author.`
* The **arguments** the model is allowed to pass come from the type hints: `query: str` and `limit: int`.
### The input schema
From those type hints the SDK generates a JSON Schema and sends it to the client during `tools/list`:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"title": "Limit", "type": "integer"}
},
"required": ["query", "limit"],
"title": "search_booksArguments"
}
```
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.)
!!! 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.
### What the model gets back
Call the tool with `{"query": "dune", "limit": 5}` and the result has two parts:
```python
result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")]
result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."}
```
`content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`.
Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **Structured Output** chapter is all about it.
### Try it
Run the server with the MCP Inspector:
```console
uv run mcp dev server.py
```
Open the URL it prints, go to the **Tools** tab, and call `search_books`.
The Inspector renders a form with a required `query` text field and a required `limit` number field. It built that form from your type hints. So will every other MCP client.
## Optional arguments
Give a parameter a default value and it stops being required. That's it. It's just Python.
```python title="server.py" hl_lines="7"
--8<-- "docs_src/tools/tutorial002.py"
```
The schema follows:
```json
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"}
},
"required": ["query"],
"title": "search_booksArguments"
}
```
`limit` left `required` and gained `"default": 10`. A client that omits it gets `10`, exactly as Python would.
## Richer schemas with `Field`
Type hints get you a long way, but sometimes you want to *describe* an argument, or constrain it.
Wrap the type in `Annotated` and add a Pydantic `Field`:
```python title="server.py" hl_lines="12-14"
--8<-- "docs_src/tools/tutorial003.py"
```
Three new things, all on the parameters:
* `Field(description=...)`: a per-argument description the model reads alongside the docstring.
* `Field(ge=1, le=50)`: numeric bounds. They land in the schema as `"minimum": 1, "maximum": 50`.
* `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those.
!!! check
Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a
tool error **before your function runs**:
```text
Input should be less than or equal to 50
```
That error goes back to the model as the tool result, and the model reads it and retries with
a valid value. You wrote `le=50` once and got self-correcting agents for free.
!!! info
If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`,
the same `Annotated`, the same validation. There is nothing MCP-specific to learn here.
## A model as a parameter
When a tool takes more than a couple of arguments, group them into a Pydantic model:
```python title="server.py" hl_lines="8-11 15"
--8<-- "docs_src/tools/tutorial004.py"
```
The `Book` schema is nested inside the tool's input schema (as a `$defs` reference), the model fills it in as a JSON object, and your function receives a **real `Book` instance**, already validated, with `.title`, `.author` and `.year` attributes.
You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down.
## `async def`
If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it.
A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server.
There is nothing else to configure.
## Names, titles, and annotations
Everything the SDK infers, you can override in the decorator:
```python title="server.py" hl_lines="8-11"
--8<-- "docs_src/tools/tutorial005.py"
```
* `title` is a human-readable name for UIs. Clients show *"Search the catalog"* instead of `search_books`.
* `annotations` are behavioural **hints** for the client:
* `read_only_hint=True`: this tool doesn't change anything.
* `open_world_hint=False`: it works on a closed set of things (this catalog), not the open web.
* The other two, `destructive_hint` and `idempotent_hint`, describe a tool that *writes*: may it
delete something, and is calling it twice the same as calling it once? The spec defines both
only for non-read-only tools, so they would say nothing on `search_books`.
A well-behaved client uses them to decide things like *"do I need to ask the user before running this?"*. They are hints, not security. Never rely on a client honouring them.
!!! tip
`name=` and `description=` are also accepted by `@mcp.tool()` if you don't want to derive them
from the function name and docstring. Most of the time you do.
## Recap
* `@mcp.tool()` on a function makes it a tool. Name from the function, description from the docstring.
* Type hints **are** the input schema. Defaults make arguments optional.
* `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums.
* A Pydantic model parameter is how you take a structured "body".
* Bad arguments are rejected for you, with an error the model can read and recover from.
* `async def` for I/O, plain `def` for everything else.
Next up, **Structured Output**: what happens to the value you `return`.