Skip to content

Commit 235f60d

Browse files
committed
docs: add the four pages readers were filing issues instead of finding
Troubleshooting, Deploy & scale, Connect to a real host, and Serving legacy clients. Each answers a question the issue tracker shows users asking repeatedly and the docs never answering, and each follows this doc set's rule that every code block is an executable file under docs_src/, included by the page and exercised by the test suite. 44 new tests; the docs suite goes from 859 to 973. Troubleshooting (top level). Every heading is the exact text of an error the SDK produces -- the CLIENT-side text where that is what a user actually sees -- followed by what it means and the one-move fix, and every quoted error is reproduced by a test. It opens with the one that wraps all the others: anyio's "ExceptionGroup: unhandled errors in a TaskGroup", which every exception escaping `async with Client(...)` arrives inside, so the first thing the page teaches is to read the last line of the paste. Deploy & scale (Running your server). The DNS-rebinding Host allowlist -- the most-reported deployment failure by a wide margin -- moves here from "Add to an existing app", which keeps a short warning and a pointer: it is a deploy gate, not an add-to-my-app concern, and its config example is now a tested file where before it was the doc set's one untested inline snippet. Then the two things "more than one worker" actually changes. A multi-round-trip retry that lands on a different worker fails with the frozen -32602 "Invalid or expired requestState" because the default sealing key is os.urandom(32) per process; the fix is RequestStateSecurity(keys=[...]) shared across instances AND the same server name on every instance, because the name is the seal's default audience claim -- the half nobody finds. Change notifications cross replicas only through a shared SubscriptionBus, a two-method Protocol you implement over your own pub/sub. Both are proved by tests that run two server instances in memory, the wrong way and the right way. Connect to a real host (Get started). A host needs one thing from you: the command that starts your server over stdio. One tested server file, then one short section per host. `mcp install` supports exactly one host -- Claude Desktop -- so the page shows the exact JSON it writes and where it writes it, and gives Claude Code, Cursor, and VS Code their one config block each. Serving legacy clients (Running your server). The streamable_http_app() you already deploy serves both protocol eras, routed per request on the version header; there is nothing to configure and no era knob. What a legacy client costs you is a session -- so more than one worker means sticky routing, since sessions live in an in-process dict -- and the one knob, stateless_http=True, is legacy-leg-only and trades away both server-to-client channels on that leg. The page's central example is one server with one Resolve-based tool serving a legacy and a modern client concurrently, entirely in memory, which is the whole pitch in one test. The pages that should route to the new ones now do: the landing page, the Get started sequence and its index, the Running your server index, the Testing hand-off, and Handling errors.
1 parent b6e4689 commit 235f60d

38 files changed

Lines changed: 1965 additions & 47 deletions

docs/get-started/first-steps.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,4 @@ That ratio is the whole point of the SDK.
136136
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
137137
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
138138

139-
Next: **[Testing](testing.md)** — one page, one in-memory client, and you're never guessing whether it works. Then each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.
139+
Next: **[Connect to a real host](real-host.md)**this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.

docs/get-started/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
New to MCP, or new to this SDK? Start here. These pages take you from nothing to a
44
working, tested server: [install the SDK](installation.md), build your
5-
[first server](first-steps.md), and [test it](testing.md) with an in-memory client.
5+
[first server](first-steps.md), [connect it to a real host](real-host.md), and
6+
[test it](testing.md) with an in-memory client.
67

78
## Run the code
89

docs/get-started/real-host.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# Connect to a real host
2+
3+
A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout.
4+
5+
Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page — two CLI commands, three JSON files — is a different place to put that same command.
6+
7+
## One server, every host
8+
9+
```python title="server.py" hl_lines="3 33-34"
10+
--8<-- "docs_src/real_host/tutorial001.py"
11+
```
12+
13+
Two tools and a resource, one file. Two things about that file matter to every host below:
14+
15+
* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module.
16+
* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`.
17+
18+
That is the last line of Python on this page. From here down it is all host configuration.
19+
20+
## The launch command
21+
22+
Every host below gets the same command:
23+
24+
```bash
25+
uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
26+
```
27+
28+
One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment — not from your shell.
29+
30+
It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree.
31+
32+
!!! warning "The version pin is not optional"
33+
v2 of this SDK is in beta, and installers never select a pre-release unless you name one. An
34+
unpinned `--with "mcp[cli]"` gives you the latest **v1.x**, which these docs do not describe.
35+
Use the exact pin from **[Installation](installation.md)**.
36+
37+
!!! tip "If a host can't find `uv`"
38+
A host spawns your server with a minimal `PATH`, and `uv` may not be on it. Replace the bare
39+
`uv` with the absolute path from `which uv` (macOS/Linux) or `where uv` (Windows). That is
40+
exactly what `mcp install` writes.
41+
42+
## Claude Desktop
43+
44+
The one host the SDK can configure for you:
45+
46+
```bash
47+
uv run mcp install server.py
48+
```
49+
50+
That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it — converting your path to an absolute one on the way, so you don't have to.
51+
52+
There is nothing to be mystified by. This is the entry it writes:
53+
54+
```json
55+
{
56+
"mcpServers": {
57+
"Bookshop": {
58+
"command": "/absolute/path/to/uv",
59+
"args": [
60+
"run",
61+
"--frozen",
62+
"--with",
63+
"mcp[cli]==2.0.0b1",
64+
"mcp",
65+
"run",
66+
"/absolute/path/to/server.py"
67+
]
68+
}
69+
}
70+
}
71+
```
72+
73+
That's the launch command from the section above with two additions: the absolute path to `uv`, and `--frozen` so `uv` never rewrites a lockfile it happens to be near. It lands in `claude_desktop_config.json`, which lives at:
74+
75+
* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
76+
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
77+
78+
You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it.
79+
80+
Fully quit Claude Desktop — not just its window — and reopen it.
81+
82+
!!! warning
83+
`mcp install` fails with `Claude app not found` if Claude Desktop's config *directory* doesn't
84+
exist yet. Install Claude Desktop and run it once — that's what creates the directory.
85+
86+
!!! tip
87+
Claude Desktop starts your server in its own process, so your shell's environment variables are
88+
not there. `uv run mcp install server.py -v API_KEY=abc123` (or `-f .env`) records them in the
89+
entry's `env` field. `--name` overrides the entry name; it defaults to the server's `name`.
90+
91+
## Claude Code
92+
93+
There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command.
94+
95+
```bash
96+
claude mcp add bookshop -- uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
97+
```
98+
99+
Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed.
100+
101+
## Cursor
102+
103+
Create `.cursor/mcp.json` in your project root.
104+
105+
```json
106+
{
107+
"mcpServers": {
108+
"bookshop": {
109+
"command": "uv",
110+
"args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
111+
}
112+
}
113+
}
114+
```
115+
116+
The same `command` plus `args`, under the same `mcpServers` key Claude Desktop uses. The server appears in Cursor's MCP settings with both tools listed.
117+
118+
## VS Code
119+
120+
Create `.vscode/mcp.json` in your project root.
121+
122+
```json
123+
{
124+
"servers": {
125+
"bookshop": {
126+
"type": "stdio",
127+
"command": "uv",
128+
"args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"]
129+
}
130+
}
131+
}
132+
```
133+
134+
Two differences from Cursor's file, and they are the only two: the wrapper key is `servers`, not `mcpServers`, and each entry declares its `type`. Confirm the trust prompt, then **MCP: List Servers** in the Command Palette shows `bookshop` running.
135+
136+
!!! note
137+
You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is
138+
enough), and Copilot Chat must be in **Agent** mode — the only mode that calls tools.
139+
140+
## It doesn't show up
141+
142+
Before you touch any host config, run the launch command yourself:
143+
144+
```bash
145+
uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py
146+
```
147+
148+
Nothing prints, and it doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (`Ctrl-C` to stop it). A traceback or an immediate exit is the real bug, and now you can read it instead of guessing at it through a host.
149+
150+
Once that command sits and waits, what's left is almost always one of three things:
151+
152+
* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
153+
* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* — not just its window closed — and reopened before an edit to `claude_desktop_config.json` takes effect.
154+
* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module — it writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
155+
156+
Claude Desktop keeps a log per server: `mcp-server-<NAME>.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
157+
158+
For anything past those three, **[Troubleshooting](../troubleshooting.md)** is the page.
159+
160+
## Recap
161+
162+
* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command.
163+
* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta.
164+
* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command — plus the absolute path to `uv` — into `claude_desktop_config.json`, so you never have to.
165+
* **Claude Code** is `claude mcp add bookshop -- <launch command>`. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`.
166+
* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout.
167+
168+
Every host on this page connected to the same file, with the same command. What that file can *expose* is the rest of these docs: **[Tools](../servers/tools.md)**, **[Resources](../servers/resources.md)**, and every transport besides stdio in **[Running your server](../run/index.md)**.

docs/get-started/testing.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,6 @@ That one line is also why these docs can promise you that their examples work: e
102102
example file is exercised by the SDK's own test suite through exactly this client. You're using the
103103
same tool the SDK uses on itself.
104104

105-
You have a working, tested server. Putting it in front of a real client, over a real
106-
transport, is **[Running your server](../run/index.md)**.
105+
You have a working, tested server. Putting it inside a real application — Claude Desktop, an
106+
IDE — is **[Connect to a real host](real-host.md)**; every other way to serve it is
107+
**[Running your server](../run/index.md)**.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ You wrote two Python functions with type hints and a docstring. The SDK does the
9292
* **[Get started](get-started/index.md)** takes you from install to a working, tested server.
9393
* Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**.
9494
* Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it.
95+
* Hunting an exact error message? **[Troubleshooting](troubleshooting.md)** is keyed by the verbatim text.
9596
* Migrating from v1? Start with the **[Migration Guide](migration.md)**.
9697
* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source.
9798
* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format:

docs/run/asgi.md

Lines changed: 9 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -37,44 +37,13 @@ Run the app on its own (`uvicorn server:app`) and you never think about either.
3737

3838
## Localhost only, until you say otherwise
3939

40-
`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the
41-
safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding
42-
protection** and accepts a request only if its `Host` header is `127.0.0.1:<port>`,
43-
`localhost:<port>`, or `[::1]:<port>`, and only if its `Origin` header, when there is one, is the
44-
`http://` form of the same. For `uvicorn server:app` on your machine that is exactly what you want:
45-
it stops a malicious web page from driving your local server through a DNS name it rebound to
46-
`127.0.0.1`.
47-
48-
It also means that **deployed behind a real hostname, the app rejects every request until you
49-
configure it**. The check runs before MCP does, the client sees only a generic transport error, and
50-
the reason is a single warning in the *server's* log:
51-
52-
```text
53-
421 Misdirected Request Invalid Host header the Host is not in the allowlist
54-
403 Forbidden Invalid Origin header the Origin is not in the allowlist
55-
```
56-
57-
`transport_security=` is how you configure it. Allowlist what you actually serve:
58-
59-
```python
60-
from mcp.server.transport_security import TransportSecuritySettings
61-
62-
security = TransportSecuritySettings(
63-
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
64-
allowed_origins=["https://app.example.com"],
65-
)
66-
app = mcp.streamable_http_app(transport_security=security)
67-
```
68-
69-
* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and
70-
`"mcp.example.com:*"` matches any port. List both.
71-
* `allowed_origins` only matters for browsers (nothing else sends `Origin`). It is the server-side
72-
twin of the CORS configuration below.
73-
* Behind a reverse proxy that already controls the `Host` header, switching the check off is the
74-
honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`.
75-
* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that
76-
hostname. It only stops the localhost default from arming the protection, which leaves every Host
77-
and Origin accepted. Say what you mean with `transport_security=` instead.
40+
Out of the box the app answers **only** requests addressed to localhost. `streamable_http_app()`
41+
cannot know which hostname it will be served behind, so it arms DNS-rebinding protection with the
42+
safest possible allowlist; on your machine that is exactly right. Deployed behind a real hostname,
43+
it means **every request is rejected with `421 Misdirected Request`** until you pass
44+
`transport_security=` an allowlist of what you actually serve — and nothing you built is even
45+
consulted first. That allowlist, and everything else between a working app and a real hostname,
46+
is **[Deploy & scale](deploy.md)**.
7847

7948
## Mounting it
8049

@@ -88,7 +57,7 @@ The moment the MCP server is *part* of a bigger application, you put the app ins
8857
* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets.
8958
* `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.
9059

91-
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`.
60+
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, but the transport's own Host allowlist (**[Deploy & scale](deploy.md)**) still runs first — without `"mcp.example.com"` in it, that route answers every one of them with a `421`.
9261

9362
!!! warning "The host app owns the lifespan"
9463
`streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it
@@ -160,7 +129,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque
160129
## Recap
161130

162131
* `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it.
163-
* Out of the box the app answers only requests addressed to localhost. Deploying behind a real hostname means passing `transport_security=TransportSecuritySettings(...)`.
132+
* Out of the box the app answers only requests addressed to localhost, and behind a real hostname it rejects everything with a `421` until you pass `transport_security=` an allowlist. **[Deploy & scale](deploy.md)** owns that, and the rest of the road to production.
164133
* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app.
165134
* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails.
166135
* Several servers in one app means several mounts and one lifespan that enters every session manager.

docs/run/authorization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,4 @@ An authorization server can also accept an enterprise identity provider's signed
122122
* `get_access_token()` in any handler is who's calling.
123123
* Authorization is an HTTP concern. `stdio` and the in-memory client never see it.
124124

125-
The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **[OAuth clients](../client/oauth-clients.md)**.
125+
The client half — discovering your authorization server and fetching the token for youis **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**.

0 commit comments

Comments
 (0)