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
2 changed files with 9510 additions and 2639 deletions

171
docs/run/asgi.md Normal file
View file

@ -0,0 +1,171 @@
# ASGI
`mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment.
For that, `mcp.streamable_http_app()` returns a **Starlette application**.
A Starlette app is an ASGI app, so anything that hosts ASGI (uvicorn, Hypercorn, another Starlette, FastAPI) can host your MCP server.
## The app
```python title="server.py" hl_lines="12"
--8<-- "docs_src/asgi/tutorial001.py"
```
`app` is an ordinary ASGI application. Hand it to any ASGI server:
```console
uvicorn server:app
```
The MCP endpoint is at `/mcp`, so a client connects to `http://127.0.0.1:8000/mcp`.
The app already carries two things:
* One route, `/mcp`: the Streamable HTTP endpoint.
* A **lifespan** that starts `mcp.session_manager`, the object that owns every live session's background work.
Run the app on its own (`uvicorn server:app`) and you never think about either.
!!! tip
`streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`,
minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds
nothing here; the next section is what it actually controls. **Running your server** covers the
options themselves.
`mcp.sse_app()` does the same for the superseded SSE transport.
## Localhost only, until you say otherwise
`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the
safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding
protection** and accepts a request only if its `Host` header is `127.0.0.1:<port>`,
`localhost:<port>`, or `[::1]:<port>`, and only if its `Origin` header, when there is one, is the
`http://` form of the same. For `uvicorn server:app` on your machine that is exactly what you want:
it stops a malicious web page from driving your local server through a DNS name it rebound to
`127.0.0.1`.
It also means that **deployed behind a real hostname, the app rejects every request until you
configure it**. The check runs before MCP does, the client sees only a generic transport error, and
the reason is a single warning in the *server's* log:
```text
421 Misdirected Request Invalid Host header the Host is not in the allowlist
403 Forbidden Invalid Origin header the Origin is not in the allowlist
```
`transport_security=` is how you configure it. Allowlist what you actually serve:
```python
from mcp.server.transport_security import TransportSecuritySettings
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
```
* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and
`"mcp.example.com:*"` matches any port. List both.
* `allowed_origins` only matters for browsers (nothing else sends `Origin`). It is the server-side
twin of the CORS configuration below.
* Behind a reverse proxy that already controls the `Host` header, switching the check off is the
honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that
hostname. It only stops the localhost default from arming the protection, which leaves every Host
and Origin accepted. Say what you mean with `transport_security=` instead.
## Mounting it
The moment the MCP server is *part* of a bigger application, you put the app inside a `Mount`. And the moment you do that, the lifespan becomes your problem:
```python title="server.py" hl_lines="18-21 25-26"
--8<-- "docs_src/asgi/tutorial002.py"
```
* `Mount("/", ...)` plus the default `/mcp` path keeps the endpoint at `/mcp`. Starlette tries routes in order and `Mount("/")` matches **every** path, so your own routes go *before* it in the list. Anything after it is unreachable.
* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets.
* `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan.
Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, so without `allowed_hosts=["mcp.example.com", "mcp.example.com:*"]` it answers every one of them with a `421`.
!!! warning "The host app owns the lifespan"
`streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it
returns, but **a mounted sub-application's lifespan never runs**. Mount the app and that
built-in lifespan is dead code. Whichever app sits at the top of your ASGI stack must enter
`mcp.session_manager.run()` in its own lifespan.
!!! check
Delete the `lifespan=lifespan` line and start the server. It starts. The route resolves.
Then the first request to `/mcp` fails with:
```text
RuntimeError: Task group is not initialized. Make sure to use run().
```
Nothing starts the session manager except its `run()`.
## Two servers, one app
Each `MCPServer` is its own app with its own session manager. Mount as many as you like; enter every manager from the one host lifespan:
```python title="server.py" hl_lines="27-30 35-36"
--8<-- "docs_src/asgi/tutorial003.py"
```
* `AsyncExitStack` enters both managers; they start together and shut down in reverse order.
* The endpoints are `/notes/mcp` and `/tasks/mcp`: the mount prefix plus the default path.
## Changing the path
That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount prefix becomes the whole public path:
```python title="server.py" hl_lines="25"
--8<-- "docs_src/asgi/tutorial004.py"
```
Now clients connect to `/notes`, not `/notes/mcp`.
## CORS for browser clients
A browser-based client needs two permissions from you: to **send** its MCP request headers, and to **read** the one MCP sends back. Both are CORS configuration on the host app, and the transport-security allowlist above has to agree with it:
```python title="server.py" hl_lines="27-30 33 35-49"
--8<-- "docs_src/asgi/tutorial005.py"
```
* `allow_headers` is the half everyone forgets. A browser **preflights** every MCP request, because `Content-Type: application/json` and the `Mcp-*` request headers are not on the CORS safelist, and a header the preflight doesn't grant is a request the browser never sends. (`allow_headers=["*"]` also works: Starlette answers a preflight with whatever it asked for.)
* `expose_headers=["Mcp-Session-Id"]` is the read half. Streamable HTTP returns the session ID in that response header, and browsers hide response headers from JavaScript unless CORS exposes them by name. Without it the client can never make its second request.
* `allow_origins` is your decision, not MCP's. Be precise, and mirror it in `allowed_origins=` above: the browser enforces CORS, but the server checks `Origin` itself, and an origin the transport doesn't trust gets a `403` even after a clean preflight.
* `allow_methods` lists the three methods Streamable HTTP uses: `POST` to send messages, `GET` to open the server-to-client stream, `DELETE` to end the session.
## Custom routes
`@mcp.custom_route()` registers a plain HTTP endpoint on the same app, for the things every deployed service needs that have nothing to do with MCP: a health check, an OAuth callback.
```python title="server.py" hl_lines="15-17"
--8<-- "docs_src/asgi/tutorial006.py"
```
* The handler is plain Starlette: an `async` function from `Request` to `Response`.
* `streamable_http_app()` picks up every custom route. `app.routes` is now `/mcp` and `/health`.
* `GET /health` answers `{"status": "ok"}` with no MCP in sight: no session, no handshake.
!!! warning
Custom routes are **never authenticated**, even when the rest of the server is. That is
deliberate: health checks and OAuth callbacks have to be reachable before any token exists.
Don't put anything private behind one.
## Recap
* `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it.
* Out of the box the app answers only requests addressed to localhost. Deploying behind a real hostname means passing `transport_security=TransportSecuritySettings(...)`.
* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app.
* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails.
* Several servers in one app means several mounts and one lifespan that enters every session manager.
* `streamable_http_path="/"` moves the endpoint to the mount prefix itself.
* Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response.
* `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`.
Once the server is reachable at a real URL, **The Client** connects to it with that URL instead of a server object.

146
docs/run/index.md Normal file
View file

@ -0,0 +1,146 @@
# Running your server
`mcp.run()` starts the server.
The only decision you make is the **transport**: how the bytes between your server and its client actually move.
## Pick a transport
| Transport | What it is | When |
|---|---|---|
| `stdio` | The host launches your file as a subprocess and speaks over its stdin and stdout. | Local servers. The default. |
| `streamable-http` | A real HTTP server listening on a port. | Anything you deploy. |
| `sse` | The older HTTP transport. | You don't. |
!!! warning
SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision.
`mcp.run(transport="sse")` still works, with its own `sse_path=` and `message_path=`
options, but it exists for clients that haven't moved. Don't build anything new on it.
## `mcp.run()`
```python title="server.py" hl_lines="12-13"
--8<-- "docs_src/run/tutorial001.py"
```
* `run()` is synchronous. It blocks for the life of the server.
* With no argument, the transport is `stdio`.
* It sits under `if __name__ == "__main__":` because everything that loads your server (`mcp dev`, `mcp run`, `mcp install`, your tests) **imports** this file. The guard keeps an import from turning into a running server.
### stdio
There is nothing to configure. The host starts your file as a child process, writes requests to its stdin, and reads responses from its stdout.
Run it yourself and you see the consequence:
```console
python server.py
```
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **Logging**.
### Try it
```console
uv run mcp dev server.py
```
The Inspector does exactly what a real host does: it launches `server.py` as a subprocess and connects to it over stdio.
You never gave it a port. There isn't one.
## Streamable HTTP
To put the same server on a port instead, name the transport (and its options) in `run()`:
```python title="server.py" hl_lines="13"
--8<-- "docs_src/run/tutorial002.py"
```
That one line builds a Starlette app and serves it with uvicorn. Clients connect to `http://127.0.0.1:3001/mcp`.
Each transport has its own keyword arguments, all on `run()`:
* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
* `json_response=True`: answer with plain JSON instead of an SSE stream.
* `stateless_http=True`: a fresh transport per request, no session tracking.
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **ASGI** covers `transport_security`.
!!! warning
Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what
your server *is*: name, version, instructions. `run()` describes how it is served. Get it
backwards and Python answers before MCP is even involved:
```text
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
```
`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **ASGI**.
## Server settings
A couple of things about running are not about the transport. They are constructor arguments:
```python title="server.py" hl_lines="3"
--8<-- "docs_src/run/tutorial003.py"
```
* `log_level`: handed to `logging.basicConfig()` the moment `MCPServer(...)` is constructed. That configures the **root** logger, so it sets the level for your own loggers too, not just the SDK's. Default `"INFO"`.
* `debug`: forwarded to the Starlette app that the HTTP transports build. Default `False`.
Both land on `mcp.settings`, which you can read back at runtime.
## The `mcp` command
The `[cli]` extra installs a small command-line tool around all of this.
`mcp dev` runs your server under the **MCP Inspector**:
```console
uv run mcp dev server.py
uv run mcp dev server.py --with pandas --with numpy
uv run mcp dev server.py --with-editable .
```
`--with` adds packages to the environment it builds; `--with-editable` installs your own package into it. It needs `npx` on your `PATH`: the Inspector is a Node.js app.
`mcp run` imports the file, finds the server object (a module-level `mcp`, `server`, or `app`), and calls `run()` on it:
```console
uv run mcp run server.py
uv run mcp run server.py:bookshop
```
The `:` suffix names the object when it isn't called `mcp`, `server`, or `app`.
Your `if __name__ == "__main__":` block never executes here: `mcp run` calls `run()` itself, and the only option it forwards is `--transport`.
`mcp install` registers the server with **Claude Desktop**, so the app launches it for you:
```console
uv run mcp install server.py --name "Bookshop"
uv run mcp install server.py -v API_KEY=abc123 -f .env
```
`-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there.
`mcp version` prints the installed SDK version.
!!! tip
`mcp dev` and `mcp run` only understand `MCPServer`. If you build with the low-level `Server`,
you run it yourself. See **The low-level Server**.
## Recap
* A **transport** is how bytes reach your server: `stdio` for a local subprocess, `streamable-http` for a port. SSE is superseded.
* `mcp.run()` picks the transport. With no argument it is `stdio`, and it blocks.
* Every transport option (`host`, `port`, `streamable_http_path`, ...) is an argument to `run()`, never to `MCPServer(...)`.
* Keep `run()` under `if __name__ == "__main__":`. Everything that loads your server imports the file first.
* `log_level=` and `debug=` are constructor arguments; they land on `mcp.settings`.
* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version.
* The transport never changes what your server *is*: all three files on this page expose the identical tool.
When `run()` itself is the limit (your server inside an app that already exists), the next step is **ASGI**.