Skip to content

Commit dc90ddf

Browse files
committed
docs: act on review feedback; fix the ASGI chapter's deployment story
Each review finding was verified against the SDK before changing anything. - The ASGI chapter taught deployments that do not work as written. With no `transport_security=`, `streamable_http_app()` auto-enables DNS-rebinding protection that accepts only localhost Host/Origin headers, so anything served behind a real hostname was answered `421 Invalid Host header` (and the CORS example's own browser origin `403 Invalid Origin header`) before MCP ran. The browser example also granted none of the `Mcp-*` request headers, so a browser's preflight blocked every request after the first. The page gains a "Localhost only, until you say otherwise" section, the CORS example now carries `TransportSecuritySettings(...)` and `allow_headers`, and two new tests pin the rejection statuses and the documented browser scenario end to end (the latter fails against the old example). - progress.md implied the in-memory timing (callbacks complete before `call_tool` returns) holds on every transport. It is guaranteed by construction in memory; on a wire dispatcher callbacks run as their own tasks and can outlive the call. The info box now says so and a new test pins the wire behaviour. The "Try it" sentence a reviewer challenged is correct as written for the connection it narrates, so it is unchanged. - tools.md's `ToolAnnotations` example paired `idempotent_hint=True` with `read_only_hint=True`, which the spec defines as meaningful only for non-read-only tools; it now shows `open_world_hint=False` and the prose explains the rule. Its `hl_lines` was also off by one, the only misaligned range out of all 70 in the book. - elicitation: the booking example re-validates the date the user accepts by sending it back through the tool, and the "Try it" names which of the page's two servers each step runs against. - installation.md leads with the pinned install command (the unpinned one installs v1.x), adds `mcp-types` to "What gets installed", and reuses the pydantic bullet for what pydantic does now that the protocol types live in `mcp-types`. The README's install command is pinned the same way. - The three tutorial closing pointers that did not follow the nav order (media, progress, logging) now hand off to the next chapter, and testing.md gains the missing final hand-off; all 31 closers were checked. - testing.md no longer calls inline-snapshot optional while showing a test that imports it.
1 parent 81326f7 commit dc90ddf

17 files changed

Lines changed: 209 additions & 46 deletions

File tree

README.v2.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ Python 3.10+.
4444
## Installation
4545

4646
```bash
47-
uv add "mcp[cli]" # or: pip install "mcp[cli]"
47+
uv add "mcp[cli]==2.0.0a2" # or: pip install "mcp[cli]==2.0.0a2"
4848
```
4949

50-
While v2 is in pre-release you must pin the version explicitly: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Use `uv add "mcp[cli]==2.0.0a2"` (check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release), and `uv run --with "mcp==2.0.0a2"` for one-off commands.
50+
The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0a2"` for one-off commands.
5151

5252
## A server in 15 lines
5353

docs/installation.md

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,25 @@
22

33
The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**.
44

5+
These docs describe **v2**, which is in alpha, so the version pin is not optional yet:
6+
57
=== "uv"
68

79
```bash
8-
uv add "mcp[cli]"
10+
uv add "mcp[cli]==2.0.0a2"
911
```
1012

1113
=== "pip"
1214

1315
```bash
14-
pip install "mcp[cli]"
16+
pip install "mcp[cli]==2.0.0a2"
1517
```
1618

17-
!!! warning "Pin the version while v2 is in alpha"
18-
v2 is published as pre-releases (`2.0.0a2`), and installers never select a pre-release unless
19-
you opt in, so a bare `uv add mcp` gives you the latest **v1.x** release, which these docs do
20-
not describe.
21-
22-
Pin the newest alpha explicitly. Find it in the
23-
[release history](https://pypi.org/project/mcp/#history):
24-
25-
```bash
26-
uv add "mcp[cli]==2.0.0a2"
27-
```
19+
!!! warning "Why the pin"
20+
Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"`
21+
gives you the latest **v1.x** release, which these docs do not describe. Check the
22+
[release history](https://pypi.org/project/mcp/#history) for the newest alpha before you copy
23+
the line above.
2824

2925
The same applies to one-off commands: `uv run --with "mcp==2.0.0a2" ...`, not `uv run --with mcp ...`.
3026

@@ -35,8 +31,9 @@ The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires
3531

3632
You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for:
3733

34+
* `mcp-types`: every protocol type (requests, results, content blocks) as its own package, versioned in lockstep with the SDK. Every `from mcp_types import ...` in these docs is this package.
3835
* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`.
39-
* [`pydantic`](https://docs.pydantic.dev/): every protocol type, all schema generation, and all validation.
36+
* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation.
4037
* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files.
4138
* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports.
4239
* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports.

docs/run/asgi.md

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,53 @@ Run the app on its own (`uvicorn server:app`) and you never think about either.
2929

3030
!!! tip
3131
`streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`,
32-
minus `port`: the port belongs to whatever serves the app. `host` is still there, but it binds
33-
nothing here; it only sets the DNS-rebinding-protection default. **Running your server** covers
34-
the options themselves.
32+
minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds
33+
nothing here; the next section is what it actually controls. **Running your server** covers the
34+
options themselves.
3535

3636
`mcp.sse_app()` does the same for the superseded SSE transport.
3737

38+
## Localhost only, until you say otherwise
39+
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.
78+
3879
## Mounting it
3980

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

50-
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.
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`.
5192

5293
!!! warning "The host app owns the lifespan"
5394
`streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it
@@ -88,17 +129,16 @@ Now clients connect to `/notes`, not `/notes/mcp`.
88129

89130
## CORS for browser clients
90131

91-
A browser-based client adds one hard requirement: it must be able to **read** the `Mcp-Session-Id` response header. Streamable HTTP returns the session ID there, and browsers hide response headers from JavaScript unless CORS exposes them by name.
92-
93-
Wrap the host app in Starlette's `CORSMiddleware`:
132+
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:
94133

95-
```python title="server.py" hl_lines="28-35"
134+
```python title="server.py" hl_lines="27-30 33 35-49"
96135
--8<-- "docs_src/asgi/tutorial005.py"
97136
```
98137

99-
* `expose_headers=["Mcp-Session-Id"]` is the line that matters. Without it the browser receives the header and refuses to show it to your code, and the client can never make a second request.
138+
* `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.)
139+
* `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.
140+
* `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.
100141
* `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.
101-
* `allow_origins` is your decision, not MCP's. Be precise here.
102142

103143
## Custom routes
104144

@@ -120,11 +160,12 @@ Wrap the host app in Starlette's `CORSMiddleware`:
120160
## Recap
121161

122162
* `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(...)`.
123164
* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app.
124165
* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails.
125166
* Several servers in one app means several mounts and one lifespan that enters every session manager.
126167
* `streamable_http_path="/"` moves the endpoint to the mount prefix itself.
127-
* Browser clients need CORS with `expose_headers=["Mcp-Session-Id"]`.
168+
* Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response.
128169
* `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`.
129170

130171
Once the server is reachable at a real URL, **The Client** connects to it with that URL instead of a server object.

docs/run/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`:
6767
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
6868
* `json_response=True`: answer with plain JSON instead of an SSE stream.
6969
* `stateless_http=True`: a fresh transport per request, no session tracking.
70-
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait.
70+
* `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`.
7171

7272
!!! warning
7373
Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what

docs/tutorial/elicitation.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ There are two modes:
1313

1414
`ctx.elicit()` takes a message and a Pydantic model:
1515

16-
```python title="server.py" hl_lines="9-11 20-23"
16+
```python title="server.py" hl_lines="9-11 20-23 25"
1717
--8<-- "docs_src/elicitation/tutorial001.py"
1818
```
1919

2020
* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **The Context**.
2121
* `AlternativeDate` is the **schema** of the answer you want.
2222
* The tool is `async def`. It has to be: it stops in the middle and waits for a person.
2323
* On any other date the tool returns straight away. It only asks when it has to.
24+
* 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.
2425

2526
### What the client receives
2627

@@ -113,7 +114,7 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(
113114

114115
### Try it
115116

116-
Start `server.py` on Streamable HTTP (**Running your server** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day.
117+
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.
117118

118119
The callback prints the question it was sent:
119120

@@ -127,7 +128,7 @@ It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the to
127128
Booked a table for 2 on 2025-12-27.
128129
```
129130

130-
Call `pay_deposit` and the same callback takes the other branch: it prints the payment link and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions.
131+
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.
131132

132133
!!! check
133134
Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day

docs/tutorial/logging.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire.
7575
* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server.
7676
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
7777

78-
Next: every request your server handles, traced and timed, in **Middleware**.
78+
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**.

docs/tutorial/media.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,4 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `
105105
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
106106
* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.
107107

108-
That is everything a tool can put *into* a result. What a tool can do *while it runs* (read the server's own resources, report progress, ask the user a question) lives on **The Context**.
108+
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**.

docs/tutorial/progress.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ The callback is an `async` function taking exactly what the server reported: `pr
5252

5353
!!! info
5454
`Client(mcp)` connects straight to the server object, in memory, the same client the **Testing**
55-
chapter is built on. Nothing here is a test-only shortcut: `progress_callback` is the same
56-
parameter whatever transport the `Client` is using.
55+
chapter is built on. `progress_callback` is the same parameter whatever transport the `Client`
56+
uses; the *timing* you are about to see is the in-memory connection's. It runs your callback
57+
inline, so every report lands before `call_tool` returns. Over a real transport the
58+
notifications race the result, and a slow callback can still be running after `call_tool` has
59+
returned.
5760

5861
### Try it
5962

@@ -111,4 +114,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor
111114
* No callback on the call means `report_progress` does nothing. Report unconditionally.
112115
* Omit `total` when you don't know it; the callback gets `None`.
113116

114-
Progress is the running tool talking *at* the client. When it needs an answer *back*, that is **Elicitation**.
117+
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.

docs/tutorial/testing.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ To run the test below you'll need two extra (development) dependencies:
2929
!!! info
3030
These docs assume you already know [`pytest`](https://docs.pytest.org/en/stable/).
3131

32-
[`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is optional: it records
33-
the output of a test as the `snapshot(...)` literal you see below, which makes a test that
34-
asserts on a whole result object much faster to write. A plain `assert` works too.
32+
[`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is what the test below
33+
uses to assert on the whole result object in one line. It records the output of a test as the
34+
`snapshot(...)` literal you see. If you'd rather not use it, drop the import and assert on the
35+
fields you care about (`result.content[0].text == "3"`) like in any other test.
3536

3637
Now the test:
3738

@@ -100,3 +101,6 @@ Leave it on in tests. It has no meaning in production code.
100101
That one line is also why the rest of this tutorial can promise you that its examples work: every
101102
example file is exercised by the SDK's own test suite through exactly this client. You're using the
102103
same tool the SDK uses on itself.
104+
105+
The tutorial ends here. Putting your tested server in front of a real client, over a real
106+
transport, is **Running your server**.

docs/tutorial/tools.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,15 +139,17 @@ There is nothing else to configure.
139139

140140
Everything the SDK infers, you can override in the decorator:
141141

142-
```python title="server.py" hl_lines="7-10"
142+
```python title="server.py" hl_lines="8-11"
143143
--8<-- "docs_src/tools/tutorial005.py"
144144
```
145145

146146
* `title` is a human-readable name for UIs. Clients show *"Search the catalog"* instead of `search_books`.
147147
* `annotations` are behavioural **hints** for the client:
148148
* `read_only_hint=True`: this tool doesn't change anything.
149-
* `idempotent_hint=True`: calling it twice is the same as calling it once.
150-
* `destructive_hint=True`: this tool deletes or overwrites something.
149+
* `open_world_hint=False`: it works on a closed set of things (this catalog), not the open web.
150+
* The other two, `destructive_hint` and `idempotent_hint`, describe a tool that *writes*: may it
151+
delete something, and is calling it twice the same as calling it once? The spec defines both
152+
only for non-read-only tools, so they would say nothing on `search_books`.
151153

152154
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.
153155

0 commit comments

Comments
 (0)