You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
28
24
29
25
The same applies to one-off commands: `uv run --with "mcp==2.0.0a2" ...`, not `uv run --with mcp ...`.
30
26
@@ -35,8 +31,9 @@ The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires
35
31
36
32
You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for:
37
33
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.
38
35
*[`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.
40
37
*[`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files.
41
38
*[`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.
42
39
*[`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.
* 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
+
38
79
## Mounting it
39
80
40
81
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
47
88
* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets.
48
89
*`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.
49
90
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`.
51
92
52
93
!!! warning "The host app owns the lifespan"
53
94
`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`.
88
129
89
130
## CORS for browser clients
90
131
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:
*`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.
100
141
*`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.
102
142
103
143
## Custom routes
104
144
@@ -120,11 +160,12 @@ Wrap the host app in Starlette's `CORSMiddleware`:
120
160
## Recap
121
161
122
162
*`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(...)`.
123
164
*`Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app.
124
165
***Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails.
125
166
* Several servers in one app means several mounts and one lifespan that enters every session manager.
126
167
*`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.
128
169
*`@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`.
129
170
130
171
Once the server is reachable at a real URL, **The Client** connects to it with that URL instead of a server object.
Copy file name to clipboardExpand all lines: docs/run/index.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`:
67
67
*`streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
68
68
*`json_response=True`: answer with plain JSON instead of an SSE stream.
69
69
*`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`.
71
71
72
72
!!! warning
73
73
Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what
* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **The Context**.
21
21
*`AlternativeDate` is the **schema** of the answer you want.
22
22
* The tool is `async def`. It has to be: it stops in the middle and waits for a person.
23
23
* 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.
24
25
25
26
### What the client receives
26
27
@@ -113,7 +114,7 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(
113
114
114
115
### Try it
115
116
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.
117
118
118
119
The callback prints the question it was sent:
119
120
@@ -127,7 +128,7 @@ It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the to
127
128
Booked a table for 2 on 2025-12-27.
128
129
```
129
130
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.
131
132
132
133
!!! check
133
134
Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day
Copy file name to clipboardExpand all lines: docs/tutorial/media.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -105,4 +105,4 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `
105
105
* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`.
106
106
*`icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects.
107
107
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**.
Copy file name to clipboardExpand all lines: docs/tutorial/progress.md
+6-3Lines changed: 6 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -52,8 +52,11 @@ The callback is an `async` function taking exactly what the server reported: `pr
52
52
53
53
!!! info
54
54
`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.
57
60
58
61
### Try it
59
62
@@ -111,4 +114,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor
111
114
* No callback on the call means `report_progress` does nothing. Report unconditionally.
112
115
* Omit `total` when you don't know it; the callback gets `None`.
113
116
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.
Copy file name to clipboardExpand all lines: docs/tutorial/tools.md
+5-3Lines changed: 5 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -139,15 +139,17 @@ There is nothing else to configure.
139
139
140
140
Everything the SDK infers, you can override in the decorator:
141
141
142
-
```python title="server.py" hl_lines="7-10"
142
+
```python title="server.py" hl_lines="8-11"
143
143
--8<--"docs_src/tools/tutorial005.py"
144
144
```
145
145
146
146
*`title` is a human-readable name for UIs. Clients show *"Search the catalog"* instead of `search_books`.
147
147
*`annotations` are behavioural **hints** for the client:
148
148
*`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`.
151
153
152
154
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.
0 commit comments