Files
modelcontextprotocol--pytho…/docs/handlers/lifespan.md
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

103 lines
4.5 KiB
Markdown

# 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](context.md)**.
### 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`.
A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.md)**.