docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
6 changed files with 89737 additions and 54 deletions
130
i18n/zh/pages/run/asgi.md
Normal file
130
i18n/zh/pages/run/asgi.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
translation:
|
||||
sections: [1062ef792791488a, 4be2b831547184a9, 374b049e770385f2, b72f6947089e6de0, b172c9db7831bb31, 70b9ece244ca1b0c, cba78e052898c3f6, f06bdb541cb0b469, fb82d526320b7cc3]
|
||||
tool: 1
|
||||
---
|
||||
# 添加到现有应用 {#add-to-an-existing-app}
|
||||
|
||||
`mcp.run("streamable-http")` 会替你启动一个 Web 服务器。有时你并不想这样:MCP 服务器只是一个更大的 Web 应用的一部分,或者你已经有现成的 ASGI 部署。
|
||||
|
||||
为此,`mcp.streamable_http_app()` 会返回一个 **Starlette 应用**。
|
||||
|
||||
Starlette 应用就是 ASGI 应用,所以任何能承载 ASGI 的东西(uvicorn、Hypercorn、另一个 Starlette、FastAPI)都能承载你的 MCP 服务器。
|
||||
|
||||
## 应用 {#the-app}
|
||||
|
||||
```python title="server.py" hl_lines="12"
|
||||
--8<-- "docs_src/asgi/tutorial001.py"
|
||||
```
|
||||
|
||||
`app` 是一个普通的 ASGI 应用。把它交给任意 ASGI 服务器即可:
|
||||
|
||||
```console
|
||||
uvicorn server:app
|
||||
```
|
||||
|
||||
MCP 端点位于 `/mcp`,所以客户端连接的是 `http://127.0.0.1:8000/mcp`。
|
||||
|
||||
这个应用已经自带两样东西:
|
||||
|
||||
* 一条路由 `/mcp`:Streamable HTTP 端点。
|
||||
* 一个**生命周期**,用来启动 `mcp.session_manager`——这个对象掌管每个活跃会话的后台工作。
|
||||
|
||||
单独运行这个应用(`uvicorn server:app`)时,这两样都不用你操心。
|
||||
|
||||
!!! tip
|
||||
`streamable_http_app()` 接受与 `mcp.run("streamable-http", ...)` 相同的关键字参数,只是少了 `port`:端口归负责承载该应用的那一方管。`host` 仍然可以传,但在这里不绑定任何东西;它实际控制什么,**[部署与扩展](deploy.md)** 有说明。各选项本身见 **[运行服务器](index.md)**。
|
||||
|
||||
`mcp.sse_app()` 为已被取代的 SSE 传输做同样的事。
|
||||
|
||||
## 默认只响应 localhost,除非你另行指定 {#localhost-only-until-you-say-otherwise}
|
||||
|
||||
默认情况下,这个应用**只**响应发往 localhost 的请求。`streamable_http_app()` 无从知道自己会被部署在哪个主机名后面,所以它以最保守的允许列表启用 DNS 重绑定防护;在你自己的机器上,这正合适。部署到真实主机名后面时,这意味着**每个请求都会被以 `421 Misdirected Request` 拒绝**,直到你通过 `transport_security=` 传入一份你实际提供服务的主机名允许列表。在那之前,你写的任何东西都不会被调用。这份允许列表,以及从一个能跑的应用到真实主机名之间的其他一切,详见 **[部署与扩展](deploy.md)**。
|
||||
|
||||
## 挂载 {#mounting-it}
|
||||
|
||||
一旦 MCP 服务器成为更大应用的**一部分**,就要把这个应用放进一个 `Mount` 里。而一旦这么做,生命周期就成了你的事:
|
||||
|
||||
```python title="server.py" hl_lines="18-21 25-26"
|
||||
--8<-- "docs_src/asgi/tutorial002.py"
|
||||
```
|
||||
|
||||
* `Mount("/", ...)` 加上默认的 `/mcp` 路径,端点仍在 `/mcp`。Starlette 按顺序尝试路由,而 `Mount("/")` 会匹配**所有**路径,所以你自己的路由要放在列表里它的**前面**。排在它后面的都无法访问。
|
||||
* `lifespan` 函数在**宿主**应用的整个生命周期内进入 `mcp.session_manager.run()`。这是人人都会忘的那一行。
|
||||
* `mcp.session_manager` 只有在调用过 `streamable_http_app()` **之后**才存在。所以路由在模块层面就构建好,而会话管理器只在生命周期函数内部才去访问。
|
||||
|
||||
Starlette 的 `Host` 路由用法相同:把 `Mount("/", ...)` 换成 `Host("mcp.example.com", ...)`,就改为按主机名而不是按路径来路由。生命周期的规则不变,传输安全的规则也不变。`Host("mcp.example.com", ...)` 路由只会收到发往该主机名的请求,但传输自身的 Host 允许列表(**[部署与扩展](deploy.md)**)仍然先执行。列表里没有 `"mcp.example.com"` 的话,这条路由对每一个请求都回以 `421`。
|
||||
|
||||
!!! warning "生命周期归宿主应用管"
|
||||
`streamable_http_app()` 把 `session_manager.run()` 接入了它返回的 Starlette 的生命周期,但**被挂载的子应用的生命周期永远不会运行**。一旦挂载,这个内置的生命周期就成了死代码。无论哪个应用位于 ASGI 栈的最顶层,都必须在自己的生命周期里进入 `mcp.session_manager.run()`。
|
||||
|
||||
!!! check
|
||||
删掉 `lifespan=lifespan` 这一行再启动服务器。能启动,路由也能解析。然后对 `/mcp` 的第一个请求会失败:
|
||||
|
||||
```text
|
||||
RuntimeError: Task group is not initialized. Make sure to use run().
|
||||
```
|
||||
|
||||
除了它的 `run()`,没有别的东西会启动会话管理器。
|
||||
|
||||
## 两个服务器,一个应用 {#two-servers-one-app}
|
||||
|
||||
每个 `MCPServer` 都是独立的应用,带有自己的会话管理器。想挂载多少就挂载多少;在同一个宿主生命周期里进入每一个管理器:
|
||||
|
||||
```python title="server.py" hl_lines="27-30 35-36"
|
||||
--8<-- "docs_src/asgi/tutorial003.py"
|
||||
```
|
||||
|
||||
* `AsyncExitStack` 进入两个管理器;它们一起启动,按相反顺序关闭。
|
||||
* 端点是 `/notes/mcp` 和 `/tasks/mcp`:挂载前缀加默认路径。
|
||||
|
||||
## 更改路径 {#changing-the-path}
|
||||
|
||||
末尾的那个 `/mcp` 就是 `streamable_http_path`。把它设为 `"/"`,挂载前缀就成了完整的公开路径:
|
||||
|
||||
```python title="server.py" hl_lines="25"
|
||||
--8<-- "docs_src/asgi/tutorial004.py"
|
||||
```
|
||||
|
||||
现在客户端连接 `/notes`,而不是 `/notes/mcp`。
|
||||
|
||||
## 面向浏览器客户端的 CORS {#cors-for-browser-clients}
|
||||
|
||||
基于浏览器的客户端需要你给两项许可:**发送**它的 MCP 请求头,以及**读取** MCP 返回的那个响应头。两者都是宿主应用上的 CORS 配置,而且上面的传输安全允许列表必须与之一致:
|
||||
|
||||
```python title="server.py" hl_lines="27-30 33 35-49"
|
||||
--8<-- "docs_src/asgi/tutorial005.py"
|
||||
```
|
||||
|
||||
* `allow_headers` 是人人都会忘的那一半。浏览器会对每个 MCP 请求做**预检**,因为 `Content-Type: application/json` 和 `Mcp-*` 请求头都不在 CORS 安全列表里,而预检没有放行的头,就意味着浏览器根本不会发出这个请求。(`allow_headers=["*"]` 也行:Starlette 会按预检请求所要求的内容原样应答。)
|
||||
* `expose_headers=["Mcp-Session-Id"]` 是读取那一半。Streamable HTTP 在这个响应头里返回会话 ID,而浏览器会对 JavaScript 隐藏响应头,除非 CORS 按名称公开它们。没有它,客户端永远发不出第二个请求。
|
||||
* `allow_origins` 由你决定,不归 MCP 管。写得精确些,并在上面的 `allowed_origins=` 里保持一致:CORS 由浏览器强制执行,但服务器自己也会检查 `Origin`,传输不信任的来源即使预检顺利通过,也会得到 `403`。
|
||||
* `allow_methods` 列出 Streamable HTTP 用到的三个方法:`POST` 发送消息,`GET` 打开服务器到客户端的流,`DELETE` 结束会话。
|
||||
|
||||
## 自定义路由 {#custom-routes}
|
||||
|
||||
`@mcp.custom_route()` 在同一个应用上注册一个普通的 HTTP 端点,用于每个部署出去的服务都需要、却与 MCP 无关的东西:健康检查、OAuth 回调。
|
||||
|
||||
```python title="server.py" hl_lines="15-17"
|
||||
--8<-- "docs_src/asgi/tutorial006.py"
|
||||
```
|
||||
|
||||
* 处理函数就是普通的 Starlette:一个从 `Request` 到 `Response` 的 `async` 函数。
|
||||
* `streamable_http_app()` 会收进每一条自定义路由。`app.routes` 现在是 `/mcp` 和 `/health`。
|
||||
* `GET /health` 应答 `{"status": "ok"}`,完全不涉及 MCP。
|
||||
|
||||
!!! warning
|
||||
自定义路由**永远不做认证**,即使服务器的其余部分做了。这是有意为之:健康检查和 OAuth 回调必须在任何令牌存在之前就能访问。不要把任何私密内容放在它后面。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `mcp.streamable_http_app()` 返回一个只有一条路由 `/mcp` 的 Starlette 应用。任何 ASGI 服务器都能运行它。
|
||||
* 默认情况下这个应用只响应发往 localhost 的请求;部署在真实主机名后面时,在你通过 `transport_security=` 传入允许列表之前,它会以 `421` 拒绝一切。这件事,以及通往生产环境的其余路程,都归 **[部署与扩展](deploy.md)** 管。
|
||||
* `Mount`(或 `Host`)把它放进更大的 Starlette 或 FastAPI 应用。
|
||||
* **挂载会让内置生命周期失效。**宿主应用的生命周期必须进入 `mcp.session_manager.run()`,否则第一个请求就会失败。
|
||||
* 一个应用里放多个服务器,意味着多个挂载,加上一个进入每个会话管理器的生命周期。
|
||||
* `streamable_http_path="/"` 把端点移到挂载前缀本身。
|
||||
* 浏览器客户端需要 CORS:`allow_headers` 放行 `Mcp-*` 请求头,`expose_headers=["Mcp-Session-Id"]` 公开响应头。
|
||||
* `@mcp.custom_route()` 在 `/mcp` 旁边添加普通的、不做认证的 HTTP 端点。
|
||||
|
||||
服务器一旦能通过真实 URL 访问,**[客户端](../client/index.md)** 就可以用这个 URL 而不是服务器对象来连接它。
|
||||
122
i18n/zh/pages/run/authorization.md
Normal file
122
i18n/zh/pages/run/authorization.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
---
|
||||
translation:
|
||||
sections: [d62c13457fc4a534, 80e73abaca6e0652, d1dc4c54cd00ec9c, 14ad3bc7904036bb, 5225f127bc1b9c77, fe1626fdd5aad1da, 4556cb7ea1a04a31]
|
||||
tool: 1
|
||||
---
|
||||
# 授权 {#authorization}
|
||||
|
||||
通过 Streamable HTTP 运行时,你的 MCP 服务器就是一个普通的 Web 服务,保护它的方式也和保护其他 Web 服务一样:用 OAuth 2.1 bearer token。
|
||||
|
||||
用 OAuth 的术语说,你的服务器是**资源服务器**。它从不负责任何人的登录,也从不签发 token。它只做一件事:查看每个请求的 `Authorization` 头,判断其中的 token 是否有效。
|
||||
|
||||
本页讲的是服务器端。负责发现你的授权服务器并获取 token 的客户端,见 **[OAuth 客户端](../client/oauth-clients.md)**。
|
||||
|
||||
## 三方角色 {#the-three-parties}
|
||||
|
||||
* **授权服务器**负责用户登录并签发访问 token。这部分不用你写,它就是你的身份提供方(Auth0、Keycloak、Entra,或者你自己的)。
|
||||
* **资源服务器**就是你的 MCP 服务器。它在每个请求上验证 token。
|
||||
* **客户端**发现你信任的是哪个授权服务器,从那里拿到 token,再以 `Authorization: Bearer <token>` 的形式发回给你。
|
||||
|
||||
整个三角关系就是这样。本页所有内容都是中间那一条。
|
||||
|
||||
## Token 验证器 {#a-token-verifier}
|
||||
|
||||
有效的 token 长什么样,SDK 没有任何预设。这由你来决定,方式是实现 **`TokenVerifier`**:
|
||||
|
||||
```python title="server.py" hl_lines="12-14 19-24"
|
||||
--8<-- "docs_src/authorization/tutorial001.py"
|
||||
```
|
||||
|
||||
* `TokenVerifier` 是一个只有一个异步方法的协议。`verify_token` 接收 `Authorization` 头里的原始 token,有效时返回一个 **`AccessToken`**,无效时返回 `None`。除此之外没有别的要实现。
|
||||
* 这个例子是在一张表里查找 token。真实的实现会验证 JWT 签名,或者调用授权服务器的 token 自省端点。那部分代码是你的,SDK 只负责调用它。
|
||||
* `token_verifier=` 和 `auth=` 永远成对出现。只传其中一个,`MCPServer(...)` 会在处理任何请求之前就抛出 `ValueError`。
|
||||
|
||||
`AuthSettings` 是你的资源服务器对外的门面:
|
||||
|
||||
* `issuer_url`:签发你的 token 的授权服务器。
|
||||
* `resource_server_url`:这个 MCP 端点的公开 URL。它指明 token 是针对**哪一个**资源的,发现文档也位于这里。
|
||||
* `required_scopes`:每个 token 都必须携带其中全部 scope。
|
||||
|
||||
!!! tip "提示"
|
||||
SDK 仓库中的 `examples/servers/simple-auth/` 有一个 `IntrospectionTokenVerifier`,它会调用真实授权服务器的 [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) 端点。大多数生产环境的验证器都是这个样子。
|
||||
|
||||
## 通过 HTTP 能得到什么 {#what-you-get-over-http}
|
||||
|
||||
授权信息存在于 HTTP 头中,所以它只存在于 HTTP 传输方式上。在你部署用的那一种上运行它:`mcp.run(transport="streamable-http")` 会把它放在 `http://127.0.0.1:8000/mcp`,其余内容详见 **[运行你的服务器](index.md)**。现在这个应用有两个路由:
|
||||
|
||||
```text
|
||||
/mcp
|
||||
/.well-known/oauth-protected-resource/mcp
|
||||
```
|
||||
|
||||
你注册了一个工具。第二个路由是 SDK 的。
|
||||
|
||||
### 发现 {#discovery}
|
||||
|
||||
对那个 well-known 路径发 `GET` 请求,会得到 **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**,直接由你的 `AuthSettings` 构建而来:
|
||||
|
||||
```json
|
||||
{
|
||||
"resource": "http://127.0.0.1:8000/mcp",
|
||||
"authorization_servers": ["https://auth.example.com/"],
|
||||
"scopes_supported": ["notes:read"],
|
||||
"bearer_methods_supported": ["header"]
|
||||
}
|
||||
```
|
||||
|
||||
一个从没听说过你服务器的客户端就是靠这份文档找到入口的:它读取 `authorization_servers`,然后去那里获取 token。这些一行都不是你写的。
|
||||
|
||||
!!! check "检查"
|
||||
不带 token(或者带一个你的验证器返回了 `None` 的 token)调用 `/mcp`,请求会被挡在门外:
|
||||
|
||||
```text
|
||||
HTTP/1.1 401 Unauthorized
|
||||
WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
{"error": "invalid_token", "error_description": "Authentication required"}
|
||||
```
|
||||
|
||||
什么都没有被解析,也没有工具运行。而 `WWW-Authenticate` 里那个 `resource_metadata` 指针,正是让发现过程自动完成的关键:401 -> 元数据文档 -> 授权服务器 -> token -> 重试。
|
||||
|
||||
!!! warning "警告"
|
||||
这些都保护不了 `stdio`。管道没有 `Authorization` 头,所以在那里永远不会询问 `token_verifier`。`stdio` 服务器的安全边界是启动它的那个进程。测试中使用的内存内 `Client(mcp)` 也一样:它直接连接到服务器对象,跳过了 HTTP 层,授权也包括在内。
|
||||
|
||||
## 调用者的身份 {#the-callers-identity}
|
||||
|
||||
在任何处理函数内部,**`get_access_token()`** 就是你的验证器为当前请求返回的那个 `AccessToken`:
|
||||
|
||||
```python title="server.py" hl_lines="4 32-35"
|
||||
--8<-- "docs_src/authorization/tutorial002.py"
|
||||
```
|
||||
|
||||
* 它在工具、资源和提示词中都能用,而且不需要传递任何东西:认证中间件按请求把它存在一个上下文变量里。
|
||||
* 你拿回的是**你的验证器构建的同一个对象**:`client_id`、`scopes`、`subject`、`expires_at`,以及你附加的任何额外 `claims`。这就是按工具制定规则的切入点:读取 scope,然后拒绝。
|
||||
* 在经过认证的 HTTP 请求之外,它返回 `None`。在内存内和通过 `stdio` 时,它永远是 `None`。
|
||||
|
||||
带上 `Authorization: Bearer alice-token` 调用 `whoami`,模型会读到:
|
||||
|
||||
```text
|
||||
alice (scopes: notes:read)
|
||||
```
|
||||
|
||||
## SDK 不做的那一半 {#the-half-the-sdk-doesnt-do}
|
||||
|
||||
SDK 给你的是资源服务器这一半:验证、公布、拒绝。它不提供登录页、同意授权页,也不提供 token。
|
||||
|
||||
想看三方如何协作,可以运行 SDK 仓库里的 `examples/servers/simple-auth/`(一个小型授权服务器,加上一个配置与本页完全相同的资源服务器),再把 `examples/clients/simple-auth-client/` 指向它,走一遍完整的发现与获取 token 的流程。
|
||||
|
||||
!!! info "信息"
|
||||
还有第二个构造函数参数 `auth_server_provider=`,它会在你的 MCP 服务器内部嵌入一个完整的授权服务器。它早于 MCP 授权规范所围绕的 AS/RS 分离。新的服务器不应该去用它。
|
||||
|
||||
授权服务器也可以接受企业身份提供方签名的断言,代替用户点击同意授权页,SDK 对这个交换的两端都提供支持。这种授权方式以及出示它的客户端,见 **[身份断言](../client/identity-assertion.md)**。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 通过 Streamable HTTP 运行时,你的服务器是 OAuth 2.1 **资源服务器**:它验证 token,从不签发 token。
|
||||
* `TokenVerifier` 是全部的集成接口:一个异步方法,传入 token,返回 `AccessToken | None`。
|
||||
* `token_verifier=` 和 `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` 永远成对出现。
|
||||
* SDK 在 `/.well-known/oauth-protected-resource/...` 发布 [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata,并对未认证的请求回应 401,其 `WWW-Authenticate` 头指向该文档。整个发现过程就是这样。
|
||||
* 在任何处理函数里,`get_access_token()` 就是调用者是谁。
|
||||
* 授权是 HTTP 层面的事。`stdio` 和内存内客户端永远看不到它。
|
||||
|
||||
客户端那一半(发现你的授权服务器并替你获取 token)见 **[OAuth 客户端](../client/oauth-clients.md)**。而一个**断言**身份、而不是向用户索要身份的客户端,见 **[身份断言](../client/identity-assertion.md)**。
|
||||
163
i18n/zh/pages/run/deploy.md
Normal file
163
i18n/zh/pages/run/deploy.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
translation:
|
||||
sections: [28221886b198784f, f88ea1f1614f3a1d, ce926d686730b6d0, 3be24f8ad8bb5ab9, 3fad24032b2224ff, f25a7f860e579ecb, e758745df6fb7b0a]
|
||||
tool: 1
|
||||
---
|
||||
# 部署与扩展 {#deploy-scale}
|
||||
|
||||
你的服务器已经能跑了。现在它需要一个真实的主机名,后面还要挂不止一个 worker。
|
||||
|
||||
这些事几乎都不归 MCP 管。ASGI 服务器、进程管理器、负载均衡器都由你自己带。这一页只讲确实归 MCP 管的那几件事:一个卡住所有部署的设置,以及"不止一个 worker"会改变 SDK 行为的两个地方。
|
||||
|
||||
## 首先:Host 白名单 {#before-anything-else-the-host-allowlist}
|
||||
|
||||
`streamable_http_app()` 无从知道自己会被放在哪个主机名后面,所以它假设最安全的答案:localhost。没有传 `transport_security=` 时,应用会开启 **DNS 重绑定防护**,只接受 `Host` 头为 `127.0.0.1:<port>`、`localhost:<port>` 或 `[::1]:<port>` 的请求。如果有 `Origin` 头,它必须是同一地址的 `http://` 形式。在你自己的机器上这正合适:它能阻止恶意网页通过一个重绑定到 `127.0.0.1` 的 DNS 名称操纵你的本地服务器。
|
||||
|
||||
部署到真实主机名后面,同样的默认值会拒绝**所有请求**,直到你另有说明。这项检查在任何 MCP 逻辑之前运行,所以你写的东西根本不会被调用:
|
||||
|
||||
```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=`。把你实际对外服务的地址加入白名单:
|
||||
|
||||
```python title="server.py" hl_lines="2 13-17"
|
||||
--8<-- "docs_src/deploy/tutorial001.py"
|
||||
```
|
||||
|
||||
* `allowed_hosts` 的条目是精确字符串:`"mcp.example.com"` 匹配不带端口的 `Host` 头,`"mcp.example.com:*"` 匹配任意端口。两个都要列上。
|
||||
* `allowed_origins` 只对浏览器有意义,因为别的客户端不发 `Origin`。它是 **[添加到现有应用](asgi.md)** 中 CORS 配置在服务器端的对应项。
|
||||
* 如果前面有一个已经控制 `Host` 头的反向代理,直接关掉这项检查才是诚实的配置:`TransportSecuritySettings(enable_dns_rebinding_protection=False)`。
|
||||
* 传一个非 localhost 的 `host=`(例如 `host="mcp.example.com"`)并**不会**把该主机名加入白名单。它只是让 localhost 默认值不再触发防护,结果是所有 Host 和 Origin 都被接受。想表达什么,就用 `transport_security=` 明确说出来。
|
||||
|
||||
!!! check
|
||||
删掉 `transport_security=security` 参数,照样部署这个应用。它能启动,`/mcp` 能路由,而每一个请求(包括一个普通的 `curl`)都会返回:
|
||||
|
||||
```text
|
||||
HTTP/1.1 421 Misdirected Request
|
||||
|
||||
Invalid Host header
|
||||
```
|
||||
|
||||
在客户端那边你找不到这几个字。`421` 是纯文本的 HTTP 响应,不是 JSON-RPC 错误,所以 MCP 客户端抛出的是一个泛泛的传输错误;它不认可的那个主机名只出现在**服务器**的日志里,是一条警告。一个刚部署好、拒绝所有连接的服务器,在证明是别的原因之前,就是 Host 白名单的问题。**[故障排查](../troubleshooting.md)** 也从这里讲起。
|
||||
|
||||
## Worker,以及谁需要粘性 {#workers-and-who-has-to-be-sticky}
|
||||
|
||||
主机名能响应之后,就在后面放不止一个 worker。SDK 没有这方面的开关;扩展一个 Starlette 应用和扩展任何 ASGI 应用一样,把对象交给一个会 fork 的东西:
|
||||
|
||||
```console
|
||||
uvicorn server:app --workers 4
|
||||
```
|
||||
|
||||
四个进程,一个套接字。接下来是每个部署都必须回答的问题:**一个请求是否必须到达处理了上一个请求的那个 worker?**
|
||||
|
||||
对使用 **2026-07-28** 协议的客户端来说,不需要。现代请求是一个自包含的 POST:前面没有 `initialize` 握手,响应上没有 `Mcp-Session-Id`,第二个请求没有任何东西需要"回到"。路由到任意 worker 即可。
|
||||
|
||||
这不是一个需要打开的模式。`stateless_http=True` 看起来像是,但传输层按 `MCP-Protocol-Version` 请求头路由,把现代请求交给现代处理函数,然后就**返回**了。读取 `stateless_http` 的那一行在这个返回**之后**。不是这个标志在 2026-07-28 路径上被忽略,而是根本走不到它。`stateless_http` 只是**旧版**那一支的开关,现代路径从构造上就是无会话的。
|
||||
|
||||
对使用规范版本 2025-11-25 或更早的旧版客户端,答案取决于这个标志:
|
||||
|
||||
| 客户端的协议版本 | 会话 | 负载均衡器必须做什么 |
|
||||
| --- | --- | --- |
|
||||
| **2026-07-28** | 无。`Mcp-Session-Id` 从不设置。 | 什么都不用。任意 worker 处理任意请求。 |
|
||||
| **2025-11-25 及更早**(默认) | `Mcp-Session-Id`,保存在某一个 worker 的内存里。 | **粘性会话。** 后续请求到达另一个 worker 会得到 `404` "Session not found"。 |
|
||||
| **2025-11-25 及更早**,加上 `stateless_http=True` | 无。 | 什么都不用。代价是服务器到客户端的反向通道(back-channel)(采样(sampling)、推送式征询(elicitation)、`roots/list`)和可恢复性。 |
|
||||
|
||||
粘性会话以及旧版那一支的代价单独有一页:**[服务旧版客户端](legacy-clients.md)**;两个时代本身见 **[协议版本](../protocol-versions.md)**。这里重要的是答案的形状:**在 2026-07-28 上你已经是无状态的,没有什么需要配置。**
|
||||
|
||||
这一页剩下的部分,是无状态**并不能**帮你解决的两件事。
|
||||
|
||||
## 跨 worker 的 `requestState` {#requeststate-across-workers}
|
||||
|
||||
**[多轮往返(multi-round-trip)](../handlers/multi-round-trip.md)** 工具需要客户端去取某样东西(一次确认、一个选择、一份凭据),所以它返回一个问题而不是答案,在重试时完成。两轮之间,客户端持有服务器铸造的一个不透明的 `request_state` 令牌。重试时服务器必须重新打开这个令牌。
|
||||
|
||||
**用什么密钥封存的?** 默认是服务器在构造时用 `os.urandom(32)` 生成的那一个。在 `--workers 4` 下就是四次构造、四个进程:四把不同的密钥,没写到任何地方,互不共享,重启即失。
|
||||
|
||||
下面是一个先问后做的工具,所在的服务器什么都没配置:
|
||||
|
||||
```python title="server.py" hl_lines="14 20"
|
||||
--8<-- "docs_src/deploy/tutorial002.py"
|
||||
```
|
||||
|
||||
第一轮到达 worker A。worker A 用**它的**密钥封存 `refund:120` 并返回令牌。客户端把问题摆到人面前,得到一个"是",然后重试。重试是一个全新的 HTTP 请求。
|
||||
|
||||
!!! check
|
||||
让这次重试到达 worker B。B 尝试解封一个不是它铸造的令牌,做不到,于是拒绝整轮请求。`refund` 从未被调用;客户端得到一个 JSON-RPC 错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": -32602,
|
||||
"message": "Invalid or expired requestState",
|
||||
"data": {"reason": "invalid_request_state"}
|
||||
}
|
||||
```
|
||||
|
||||
这条消息是**固定的**。过期、被篡改、针对不同参数重放,或者(真实部署中最常见的原因)由兄弟 worker 封存:客户端每次收到的都一样,线路上从不透露是哪项检查失败了。真正的原因是服务器日志里的一条 `WARNING`:
|
||||
|
||||
```text
|
||||
requestState rejected on tools/call: unknown key
|
||||
```
|
||||
|
||||
一个在单 worker 下正常、到两个 worker 时开始**时而**失败的多轮往返工具,就是这个问题。两轮仍然必须到达同一个进程,所以它失败的频率恰好等于负载均衡器把它们分开的频率。
|
||||
|
||||
两轮是两个独立的 HTTP 请求,好几种平常的情况都会把它们分开:按请求均衡的代理、中途断开的连接、一次部署或重启、一个持久化了 `request_state` 并从完全不同的进程恢复的客户端(**[自己驱动循环](../handlers/multi-round-trip.md#driving-the-loop-yourself)**)。这些都算"另一个 worker"。
|
||||
|
||||
解决办法是一个参数。它有**两**半。
|
||||
|
||||
```python title="server.py" hl_lines="1 12 14"
|
||||
--8<-- "docs_src/deploy/tutorial003.py"
|
||||
```
|
||||
|
||||
* **`keys=[...]`** 是大家都能找到的那一半。给每个实例同一个密钥(至少 32 字节),每个实例就能解封任何兄弟实例铸造的令牌。`keys[0]` 封存,列表中的每一把都能解封,这就是轮换环;**[轮换密钥](../handlers/multi-round-trip.md#rotating-keys)** 讲的是如何不停机地转动它。
|
||||
* **服务器的名字**是几乎没人找得到的那一半,也是共享了密钥之后跨实例重试仍然失败的原因。每个封存的令牌都把服务器的 `name` 作为 **audience 声明**带上,解封时严格校验。从同一份代码构建的两个实例名字相同,永远不会察觉这一点。把它们命名区分开(`MCPServer(f"billing-{POD}")` 看上去像是良好的可观测性习惯),每一次跨实例重试就会和上面一模一样地被拒绝,不管有没有共享密钥。日志里写的是 `audience` 而不是 `unknown key`;客户端分辨不出区别。
|
||||
|
||||
密钥铸造一次,把同一个值交给每个实例。这就是 SDK 自己的错误消息在你传入不足 32 字节时让你运行的命令:
|
||||
|
||||
```console
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
!!! warning "相同的密钥,**以及**相同的名字"
|
||||
多实例部署两者都必须共享。如果每实例的名字对你来说不可或缺,那就给整个集群一个显式的 audience:`RequestStateSecurity(keys=[...], audience="billing")`。这样每个实例无论叫什么,都在 `"billing"` 下铸造和接受令牌。
|
||||
|
||||
关于封存的其余一切见 **[保护 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**:它绑定什么、每轮的 `ttl`(默认 600 秒)、自带编解码器、为什么未配置的默认值在 `stdio` 上正合适。这一页的全部贡献是一张两项的清单:**相同的密钥,相同的名字。**
|
||||
|
||||
!!! info
|
||||
即使你从没写过 `InputRequiredResult`,你也在这条路径上。参数里用了 `Resolve(...)`(**[依赖](../handlers/dependencies.md)**)的工具就是多轮往返工具,SDK 替它铸造并封存 `request_state`。同样的默认密钥,同样的跨 worker 失败,同样的修复办法。
|
||||
|
||||
## 跨副本的变更通知 {#change-notifications-across-replicas}
|
||||
|
||||
客户端的 `subscriptions/listen` 流是一个长时间存活的响应,所以它整个生命期都钉在一个副本上。在**另一个**副本上发布的 `ctx.notify_resource_updated(...)` 必须能到达它。
|
||||
|
||||
两者之间的接缝是 `SubscriptionBus`。你给服务器的总线就是所有发布进入、所有打开的流监听的那一个,所以把同一个总线交给每个副本:
|
||||
|
||||
```python title="server.py" hl_lines="2 7 9"
|
||||
--8<-- "docs_src/deploy/tutorial004.py"
|
||||
```
|
||||
|
||||
扇出的过程完全不关心一个流挂在哪个服务器对象上。持有同一个 `InMemorySubscriptionBus` 的两个服务器已经是这样:在其中一个上打开监听流,在另一个上 `edit_note`,流就能收到。这个内存总线只能跨同一进程内的服务器对象,所以它是模型,不是部署方案:
|
||||
|
||||
* 跨真正的进程时,**SDK 没有提供任何能帮上忙的总线。** `SubscriptionBus` 是一个两方法的 `Protocol`(`publish` 和 `subscribe`),你在自己的 pub/sub 后端(Redis、NATS,或任何你已经在跑的东西)上实现它,并作为 `MCPServer(subscriptions=...)` 传入。**[订阅](../handlers/subscriptions.md#scaling-past-one-process)** 有示意代码和契约。
|
||||
* 总线承载的是四种小的有类型事件,从来不是 JSON-RPC。确认、过滤和流的生命周期都留在 SDK 里,所以你的总线不可能破坏协议;它只能在进程之间搬运事件。
|
||||
* 流**不可**恢复,事件**不会**重放。丢失一个副本就丢掉它的流;客户端重新监听、重新获取。没有需要共享的事件存储,也没有别的需要配置。这是横向扩展真正只是"多来几份"的唯一一处。
|
||||
|
||||
## SDK 不提供什么 {#what-the-sdk-does-not-give-you}
|
||||
|
||||
`MCPServer` 是一个协议实现,不是应用服务器。你接下来会去找的那些部署开关是故意缺席的:
|
||||
|
||||
* **没有 `workers=`。** `mcp.run("streamable-http")` 启动恰好一个 uvicorn 进程,也永远只会启动一个。多进程就是把 `streamable_http_app()` 交给你本来部署 ASGI 用的东西:`uvicorn --workers`、gunicorn、你平台的进程管理器。这一页刻意不做它们任何一个的教程;它们自己的文档比这里照抄一份要好。
|
||||
* **没有健康检查路由。** `@mcp.custom_route("/health", methods=["GET"])` 就是全部答案,而且即使服务器其余部分有认证,它也从不认证。这对存活探针是对的,对任何私密内容是错的。**[添加到现有应用](asgi.md#custom-routes)** 有一个示例。
|
||||
* **没有生产设置对象。** `MCPServer` 上没有地方写超时、TLS、优雅关闭或连接数限制,因为这些都不是它的职责。它们属于你的 ASGI 服务器,在那里配置。**[运行你的服务器](index.md)** 讲了构造函数**确实**接受的那几个设置。
|
||||
* **没有自带的 `EventStore`,在 2026-07-28 上也用不着。** 可恢复性是旧版有状态那一支的特性;现代交换是一个 POST、一个响应,没有什么可恢复的。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 默认情况下,这个应用只响应发往 localhost 的请求。`transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` 是上线的关卡:在你传入它之前,真实主机名后面的每个请求都是 `421`,原因只在服务器日志里。
|
||||
* 在 2026-07-28 上没有会话,负载均衡器没有什么可粘的。`stateless_http=True` 是只对旧版有效的开关,因为现代请求在读到这个标志之前就已经被路由并响应了。
|
||||
* 默认的 `requestState` 密钥是 `os.urandom(32)`,按进程铸造。到达另一个 worker 的多轮往返重试会以 `-32602` “Invalid or expired requestState” 失败。
|
||||
* 修复办法是 `RequestStateSecurity(keys=[...])` **并且**每个实例使用相同的服务器名字。名字是令牌默认的 audience 声明。相同的密钥,相同的名字。
|
||||
* 变更通知通过一个共享的 `SubscriptionBus` 跨副本传递。SDK 唯一的实现是进程内的;在你自己的 pub/sub 上实现那个两方法的 `Protocol` 要由你来写。
|
||||
* 没有 `workers=`,没有健康路由,没有生产设置对象。自带 ASGI 服务器。
|
||||
|
||||
真实主机名前面还需要的另一样东西是令牌:**[授权](authorization.md)**。
|
||||
149
i18n/zh/pages/run/index.md
Normal file
149
i18n/zh/pages/run/index.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
translation:
|
||||
sections: [fea8d769ff9edeba, ce8e2ad42f29ef71, 0d705efb19cf99c2, 7a53ead3e704a7f0, 9adc400e8c88e854, 318893ad8e2e9924, 6b63ab96b34476c0]
|
||||
tool: 1
|
||||
---
|
||||
# 运行服务器 {#running-your-server}
|
||||
|
||||
`mcp.run()` 启动服务器。
|
||||
|
||||
唯一需要做的决定是**传输方式**:服务器和客户端之间的字节究竟如何流动。
|
||||
|
||||
## 选择传输方式 {#pick-a-transport}
|
||||
|
||||
| 传输方式 | 是什么 | 何时使用 |
|
||||
|---|---|---|
|
||||
| `stdio` | 宿主把你的文件作为子进程启动,通过它的 stdin 和 stdout 通信。 | 本地服务器。默认值。 |
|
||||
| `streamable-http` | 真正的 HTTP 服务器,监听一个端口。 | 任何要部署的东西。 |
|
||||
| `sse` | 较旧的 HTTP 传输方式。 | 不要用。 |
|
||||
|
||||
!!! warning
|
||||
SSE 在 2025-03-26 协议修订版中已被 Streamable HTTP 取代。`mcp.run(transport="sse")` 仍然可用,也有自己的 `sse_path=` 和 `message_path=` 选项,但它只是为还没迁移的客户端留着的。不要在它之上构建任何新东西。
|
||||
|
||||
## `mcp.run()` {#mcprun}
|
||||
|
||||
```python title="server.py" hl_lines="12-13"
|
||||
--8<-- "docs_src/run/tutorial001.py"
|
||||
```
|
||||
|
||||
* `run()` 是同步的。服务器存活多久,它就阻塞多久。
|
||||
* 不带参数时,传输方式是 `stdio`。
|
||||
* 它放在 `if __name__ == "__main__":` 之下,因为所有加载服务器的东西(`mcp dev`、`mcp run`、`mcp install`、你的测试)都会**导入**这个文件。这个保护条件防止一次导入变成一个运行中的服务器。
|
||||
|
||||
### stdio {#stdio}
|
||||
|
||||
没有什么需要配置的。宿主把你的文件作为子进程启动,把请求写入它的 stdin,再从它的 stdout 读取响应。
|
||||
|
||||
自己运行一下,就能看出这意味着什么:
|
||||
|
||||
```console
|
||||
python server.py
|
||||
```
|
||||
|
||||
什么也不打印,也不返回。它在 stdin 上等着宿主先开口。
|
||||
|
||||
这也意味着 stdout **就是线路**。服务期间,SDK 把线路移到一个私有描述符上,并把**刷新**到 stdout 的输出(子进程写入它继承的 stdout、刷新过的 `print()`)转到 stderr,在那里不会破坏数据流。在开始服务**之前**就刷新到 stdout 的输出(包装脚本的 echo、导入时的无缓冲 print)仍然会落到线路上;一直缓冲到解释器退出时才排空的 `print()` 也一样。对于真正想要的输出,`logging` 模块才是正确的工具:它的 handler 会在每条记录产生时就把它刷新到 stderr。详见 **[日志记录](../handlers/logging.md)**。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
Inspector 做的事和真实宿主完全一样:它把 `server.py` 作为子进程启动,通过 stdio 连接它。
|
||||
|
||||
你从没给过它端口。根本就没有端口。
|
||||
|
||||
## Streamable HTTP {#streamable-http}
|
||||
|
||||
要把同一个服务器放到端口上,在 `run()` 里指明传输方式(及其选项):
|
||||
|
||||
```python title="server.py" hl_lines="13"
|
||||
--8<-- "docs_src/run/tutorial002.py"
|
||||
```
|
||||
|
||||
这一行会构建一个 Starlette 应用并用 uvicorn 提供服务。客户端连接到 `http://127.0.0.1:3001/mcp`。
|
||||
|
||||
每种传输方式都有自己的关键字参数,全都在 `run()` 上:
|
||||
|
||||
* `host` / `port`:监听的位置。默认 `127.0.0.1` 和 `8000`。
|
||||
* `streamable_http_path`:MCP 端点所在的路径。默认 `/mcp`。
|
||||
* `json_response=True`:用单个 JSON 正文回应每个 POST,而不是 SSE 流。这个正文只容得下响应本身,别的什么都放不下,所以在请求中途回调客户端的工具(`ctx.elicit()`、采样(sampling))会在这一段抛出 `NoBackChannelError`;与进行中的调用绑定的通知(`ctx.report_progress()` 的进度、每次调用的日志消息)会被丢弃;独立的 `GET` 流仍然承载与之无关的通知。
|
||||
* `stateless_http=True`:每个请求一个全新的传输,不跟踪会话。
|
||||
* `max_request_body_size`:接受的最大 POST 正文大小,单位为字节。默认 4 MiB;更大的请求在解析或创建会话之前就会收到 HTTP 413。只有当合法的 MCP 消息确实超过这个大小时才调高它。
|
||||
* `event_store`、`retry_interval`、`transport_security`:可恢复性和 DNS 重绑定防护。它们可以先放一放,等部署到 localhost 以外的地方再说;**[部署与扩展](deploy.md)** 介绍了 `transport_security`。
|
||||
|
||||
!!! warning
|
||||
传输选项传给 `run()`,**不是** `MCPServer(...)`。构造函数描述服务器**是什么**:名称、版本、说明。`run()` 描述它如何对外提供服务。搞反了,Python 在 MCP 介入之前就会报错:
|
||||
|
||||
```text
|
||||
TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'
|
||||
```
|
||||
|
||||
`run()` 是捷径。一旦需要更多(把服务器挂载到现有应用里、一个进程里跑两个服务器、为浏览器客户端配置 CORS),就要自己构建 ASGI 应用,再交给任意 ASGI 服务器运行。这就是 **[添加到现有应用](asgi.md)** 的内容。
|
||||
|
||||
## 服务器设置 {#server-settings}
|
||||
|
||||
运行方面有几件事与传输方式无关。它们是构造函数参数:
|
||||
|
||||
```python title="server.py" hl_lines="3"
|
||||
--8<-- "docs_src/run/tutorial003.py"
|
||||
```
|
||||
|
||||
* `log_level`:在构造 `MCPServer(...)` 的那一刻传给 `logging.basicConfig()`。它配置的是**根** logger,所以也会设置你自己的 logger 的级别,而不只是 SDK 的。默认 `"INFO"`。
|
||||
* `debug`:转发给 HTTP 传输构建的 Starlette 应用。默认 `False`。
|
||||
|
||||
两者都落在 `mcp.settings` 上,可以在运行时读回。
|
||||
|
||||
## `mcp` 命令 {#the-mcp-command}
|
||||
|
||||
`[cli]` 附加依赖会安装一个把这些都包起来的小型命令行工具。
|
||||
|
||||
`mcp dev` 在 **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` 往它构建的环境里添加包;`--with-editable` 把你自己的包安装进去。它需要 `PATH` 上有 `npx`:Inspector 是一个 Node.js 应用。
|
||||
|
||||
`mcp run` 导入文件,找到服务器对象(模块级的 `mcp`、`server` 或 `app`),然后对它调用 `run()`:
|
||||
|
||||
```console
|
||||
uv run mcp run server.py
|
||||
uv run mcp run server.py:bookshop
|
||||
```
|
||||
|
||||
当对象不叫 `mcp`、`server` 或 `app` 时,用 `:` 后缀指明它的名字。
|
||||
|
||||
在这里,`if __name__ == "__main__":` 块永远不会执行:`mcp run` 自己调用 `run()`,它唯一转发的选项是 `--transport`。
|
||||
|
||||
`mcp install` 把服务器注册到 **Claude Desktop**,让这个应用替你启动它:
|
||||
|
||||
```console
|
||||
uv run mcp install server.py --name "Bookshop"
|
||||
uv run mcp install server.py -v API_KEY=abc123 -f .env
|
||||
```
|
||||
|
||||
`-v KEY=VALUE` 和 `-f .env` 把环境变量记录在该条目里。Claude Desktop 在它自己的进程里启动你的服务器,你 shell 里的环境变量那里没有。
|
||||
|
||||
Claude Desktop 是 `mcp install` 唯一认识的宿主。其他宿主(Claude Code、Cursor、VS Code)都在各自的配置文件里接受同样的启动命令,**[连接到真实的宿主](../get-started/real-host.md)** 逐一介绍了它们。
|
||||
|
||||
`mcp version` 打印已安装的 SDK 版本。
|
||||
|
||||
!!! tip
|
||||
`mcp dev` 和 `mcp run` 只认 `MCPServer`。如果用底层的 `Server` 构建,就要自己运行它。见 **[底层 Server](../advanced/low-level-server.md)**。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* **传输方式**是字节到达服务器的方式:本地子进程用 `stdio`,端口用 `streamable-http`。SSE 已被取代。
|
||||
* `mcp.run()` 选择传输方式。不带参数时是 `stdio`,并且会阻塞。
|
||||
* 每个传输选项(`host`、`port`、`streamable_http_path`……)都是 `run()` 的参数,绝不是 `MCPServer(...)` 的。
|
||||
* 把 `run()` 放在 `if __name__ == "__main__":` 之下。所有加载服务器的东西都会先导入这个文件。
|
||||
* `log_level=` 和 `debug=` 是构造函数参数;它们落在 `mcp.settings` 上。
|
||||
* `mcp dev` 用于 Inspector,`mcp run` 执行文件,`mcp install` 用于 Claude Desktop,`mcp version` 查看版本。
|
||||
* 传输方式永远不会改变服务器**是什么**:本页的三个文件暴露的是完全相同的工具。
|
||||
|
||||
当 `run()` 本身成了限制(服务器要放进一个已经存在的应用里),看 **[添加到现有应用](asgi.md)**。需要真正的主机名和不止一个 worker,看 **[部署与扩展](deploy.md)**。如果有些客户端还停留在 2025-11-25 或更早的规范版本,**[为旧版客户端提供服务](legacy-clients.md)** 有好消息。
|
||||
116
i18n/zh/pages/run/legacy-clients.md
Normal file
116
i18n/zh/pages/run/legacy-clients.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
translation:
|
||||
sections: [3d1663c18edc824c, d4fd37009a13f03d, af9f398a5a8b679a, 470c2dd144294d69, 8e45827e6d24e8c8, 91dfd0ce98ebb03c]
|
||||
tool: 1
|
||||
---
|
||||
# 服务旧版客户端 {#serving-legacy-clients}
|
||||
|
||||
MCP 有两个协议时代:`initialize` 握手时代(到规范版本 `2025-11-25` 为止)和现代时代(`2026-07-28`)。**[协议版本](../protocol-versions.md)** 专门讲这一划分本身。
|
||||
|
||||
本页讲的是这一划分的服务器端,答案一句话就能说完:**你已经部署的 `streamable_http_app()` 同时服务两者。**
|
||||
|
||||
SDK 按 `MCP-Protocol-Version` 头路由每个请求。声明 `2026-07-28` 的请求交给现代一侧处理。声明握手时代版本的请求,或者根本不带这个头的请求(2026 之前的客户端的 `initialize` 就是这样到达的),则走这些客户端期望的传输方式:`initialize` 握手、会话,一应俱全。这一切按请求发生,在你的代码之前,就在这一个应用上。
|
||||
|
||||
所以旧版客户端不是你要**专门为之**构建什么的对象,而是会**连接到**你已经写好的服务器的东西。什么都不用配置。
|
||||
|
||||
!!! note
|
||||
真的什么都没有。没有 `legacy=` 选项,没有版本白名单,也没有办法拒绝或禁用某个时代:`streamable_http_app()` 上没有,`run()` 上没有,会话管理器上也没有。两个时代始终开启。那个签名里最接近按时代开关的东西是 `stateless_http`,本页大部分内容都在讲它。
|
||||
|
||||
## 一个处理函数,两个时代 {#one-handler-both-eras}
|
||||
|
||||
下面是一个需要向用户提问的工具,以及两个时代的客户端分别调用它:
|
||||
|
||||
```python title="server.py" hl_lines="24 37-38"
|
||||
--8<-- "docs_src/legacy_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
`reserve` 需要一样模型没有提供的东西:要几本。工具用 `Annotated[..., Resolve(ask_quantity)]` 来声明这一点(详见 **[依赖](../handlers/dependencies.md)**)。`reserve` 里没有任何地方提到版本、检查能力或做分支。
|
||||
|
||||
两个客户端**同时**打开,连的是同一个 `mcp` 对象。`mode="legacy"` 会执行 `initialize` 握手:这正是 2026 之前的客户端打开的那种连接。另一个取默认值,落在 `2026-07-28` 上。
|
||||
|
||||
```text
|
||||
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
|
||||
2026-07-28 {'result': "Reserved 2 of 'Dune'."}
|
||||
```
|
||||
|
||||
同一个服务器,同一个处理函数,同一个答案。整个功能就是这样。
|
||||
|
||||
值得停下来看看它是**怎么**做到的,因为这两个客户端是在两条完全不同的线路上被问到同一个问题的。`2026-07-28` 连接没有供服务器发送请求的通道,所以 `Resolve` 把问题放在工具结果里返回,客户端带着答案重试了这次调用(**[多轮往返(multi-round-trip)请求](../handlers/multi-round-trip.md)**)。`2025-11-25` 连接没有这种机制;在那里,`Resolve` 在调用中途发出一个实时的 `elicitation/create` 请求并等待。两种你都没写。`Resolve` 读取连接协商出的版本并做选择;无论哪种,工具函数体看到的都是一个 `AcceptedElicitation`。
|
||||
|
||||
!!! tip
|
||||
这种跨时代可移植性正是应该基于 `Resolve` 这个 API 来构建的**原因**。它的前辈 `ctx.elicit()`(**[征询(elicitation)](../handlers/elicitation.md)**)永远只发送 `elicitation/create`,所以永远只在旧版连接上有效。在 `2026-07-28` 连接上这个调用会失败。如果某个工具还在用它,修复办法就是上面看到的那样,而不是加版本检查。
|
||||
|
||||
## 旧版会话的代价 {#what-a-legacy-session-costs-you}
|
||||
|
||||
路由是免费的,会话不是。
|
||||
|
||||
`2026-07-28` 连接是**无会话**的:每个请求各自独立,现代一侧从不签发 `Mcp-Session-Id`。旧版连接正好相反。2026 之前的客户端一发送 `initialize`,SDK 就会生成一个 `Mcp-Session-Id`,在响应头里返回,并在它背后保留一条活的记录,供该客户端之后的请求查找:协商出的版本、打开的流、一个驱动会话的后台任务。
|
||||
|
||||
这条记录就是一个**普通的进程内 `dict`**。没有分布式会话存储,也没办法接入一个。
|
||||
|
||||
只有一个 worker 时这一点看不出来。有两个时,它就是全部问题所在:一个带着 `Mcp-Session-Id` 的请求落到没有生成它的 worker 上,在那个 dict 里什么也找不到,得到的回答是 `404`(`Session not found`),而不是工具结果。所以一旦运行多于一个 worker,**旧版客户端就需要粘性路由**:会话里的每个请求都必须到达发起这个会话的那个进程。现代客户端从不需要;它们没有会话可粘。**[部署与扩展](deploy.md)** 讲了粘性以及运行多个实例的其他一切。
|
||||
|
||||
!!! warning
|
||||
`event_store=` 看起来像是解决办法,其实不是。它是**可恢复性**(向重连到**同一个**会话的客户端重放错过的 SSE 事件),不是会话存储。它永远不会让一个会话能从另一个进程访问到。
|
||||
|
||||
## 唯一的开关:`stateless_http` {#the-one-knob-stateless_http}
|
||||
|
||||
如果粘性是你不愿付的代价,那么恰好有一样东西可以改。
|
||||
|
||||
```python title="server.py" hl_lines="28"
|
||||
--8<-- "docs_src/legacy_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
这就是页面开头的那个服务器,加上一个关键字参数。`stateless_http=True` 让旧版这一路改为每个请求建一个用完即弃的会话:不签发 `Mcp-Session-Id`,请求之间什么都不记,所以任何 worker 都能服务任何请求,负载均衡器想怎么分就怎么分。
|
||||
|
||||
关于它,有两点比它做了什么更重要。
|
||||
|
||||
**它只影响旧版这一路。**请求在读取 `stateless_http` **之前**就已经按版本头路由了,所以现代路径根本看不到它。`2026-07-28` 连接本来就是无会话的,两种取值下完全一样。
|
||||
|
||||
**它会让这一路失去两条服务器到客户端的通道。**只活一个 `POST` 的会话,没有供服务器推送请求的流,也没有供它推送通知的独立流。每个服务器发起的请求都会抛出 `NoBackChannelError`:`ctx.elicit()`、已退役的采样(sampling)和根目录(roots)调用(**[已弃用的功能](../deprecated.md)**),以及——没错——`Resolve` 向**旧版**客户端提问。通知连错误都没有;它们被悄悄丢弃。
|
||||
|
||||
!!! note
|
||||
`json_response=True` 不是那个开关,但它在**每一个**旧版会话上都要付一半同样的代价:用一个 JSON 正文回答的 `POST` 没有供请求范围通道使用的流,所以请求中途的 `ctx.elicit()` 会抛出同样的 `NoBackChannelError`,与该请求绑定的通知会被丢弃。会话的独立流不受影响:无关的通知仍然能到达。
|
||||
|
||||
!!! check
|
||||
故意做错一次。`reserve` 就是刚才同时服务两个客户端的那个工具。用 `stateless_http=True` 部署它,通过 HTTP 连上同样的两个客户端,分别调用它。
|
||||
|
||||
现代客户端仍然收到 `Reserved 2 of 'Dune'.`,现代这一路没变。
|
||||
|
||||
旧版客户端的调用不会以模型能读到的 `is_error` 结果返回。整个请求失败了,是一个顶层协议错误:
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.
|
||||
```
|
||||
|
||||
`Resolve` 没能救你。在 `2025-11-25` 连接上它**必须**发送 `elicitation/create`,而它需要的通道正是 `stateless_http=True` 放弃掉的东西。跨时代可移植的代码不等于不需要反向通道(back-channel)的代码。
|
||||
|
||||
所以这是一个实实在在的取舍,而且只存在于旧版这一路:**有会话且粘性,或者无状态且单向。**如果你的工具从不回调客户端,`stateless_http=True` 就是免费的,应该用它。如果会回调,就保留会话,保持路由粘性。
|
||||
|
||||
## 你的代码真正分叉的地方 {#where-your-code-actually-forks}
|
||||
|
||||
几乎没有。
|
||||
|
||||
工具、资源、提示词、结构化输出、进度、错误:它们都不在乎是哪个时代调用的。`initialize` 握手、`Mcp-Session-Id`、独立流、结束会话的 `DELETE`:全归 SDK 管,处理函数一个都看不到。交互式输入是两个时代在线路上**真正**不同的地方,而 `Resolve` 的存在就是为了让它不成为你的问题:你刚刚看过一个工具同时服务两者。
|
||||
|
||||
只剩下恰好一件事,就是**变更通知**,因为两个时代在不同的管道上监听:
|
||||
|
||||
* `2026-07-28` 客户端打开一个 `subscriptions/listen` 流并读取订阅总线。`ctx.notify_resource_updated()`(以及 `notify_tools_changed()`、`notify_prompts_changed()`、`notify_resources_changed()`)发布到那里,而且**只**发布到那里。详见 **[订阅](../handlers/subscriptions.md)**。
|
||||
* 旧版客户端读取它的会话保持打开的独立流。`ctx.session.send_resource_updated()`(以及 `send_tool_list_changed()` 等)写入承载这次请求的**连接**:对旧版会话来说,就是它的独立流。现代连接没有地方放它:通过 HTTP 没有这样的通道,通过 stdio 这四类变更通知只走 `subscriptions/listen` 流,所以在现代连接上这条通知会被悄悄丢弃。
|
||||
|
||||
通过 HTTP,两个调用都到不了另一个时代的客户端。要通知所有人,两个都调用:
|
||||
|
||||
```python title="server.py" hl_lines="19-20"
|
||||
--8<-- "docs_src/legacy_clients/tutorial003.py"
|
||||
```
|
||||
|
||||
两行,没有 `if`,没有版本检查,就完事了。因为旧版客户端存在而让处理函数做法不同的事情,全部清单就这些。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 一个 `streamable_http_app()` 服务两个协议时代。SDK 按 `MCP-Protocol-Version` 头路由每个请求;没有什么要配置,也没有什么时代开关可找。
|
||||
* 旧版客户端的代价是一个会话:一条进程内的 `Mcp-Session-Id` 记录,背后没有分布式存储。多于一个 worker 就意味着**粘性路由**,否则错的 worker 会回答 `404 Session not found`。多 worker 的情况详见 **[部署与扩展](deploy.md)**。
|
||||
* `stateless_http=True` 是唯一的开关,而且**只作用于旧版这一路**。它为旧版客户端换来自由的负载均衡,代价是这一路上两条服务器到客户端的通道:服务器发起的请求抛出 `NoBackChannelError`(在客户端是顶层错误,不是 `is_error` 结果),通知被丢弃。
|
||||
* `2026-07-28` 连接无论如何都是无会话的。`stateless_http` 永远碰不到它。
|
||||
* 处理函数代码只在恰好一个地方按时代分叉:变更通知。`ctx.notify_*` 送达 `subscriptions/listen` 客户端;`ctx.session.send_*` 送达旧版会话。两个都调用。
|
||||
* 其他一切(包括通过 `Resolve` 向用户要输入)在构造上就是跨时代可移植的。把现代的写法写一次就够了。
|
||||
85
i18n/zh/pages/run/opentelemetry.md
Normal file
85
i18n/zh/pages/run/opentelemetry.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
translation:
|
||||
sections: [bc0227014724fa49, 15738c2f7fd67d86, a2c17bbe3f707e2f, d0d853376f162c06, b6368643fcc1c8d8, 902e33e17564a607]
|
||||
tool: 1
|
||||
---
|
||||
# OpenTelemetry {#opentelemetry}
|
||||
|
||||
你的服务器已经自带追踪,什么都不用加。
|
||||
|
||||
你创建的每个服务器都会为它处理的每条消息发出一个 [OpenTelemetry](https://opentelemetry.io/) span。这不是你写的,也不需要你导入。调用 `MCPServer(...)` 的那一刻,它就在了。
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/opentelemetry/tutorial001.py"
|
||||
```
|
||||
|
||||
这就是一个完整的、带追踪的服务器。调用 `search_books`,就会为它创建一个 span。低层的 `Server` 也一样:追踪在两者上都有。
|
||||
|
||||
## 你能得到什么 {#what-you-get}
|
||||
|
||||
每条入站消息都会变成一个 `SERVER` span,名字由方法及其目标组成。所以针对 `search_books` 的 `tools/call` 对应的 span 是 `tools/call search_books`,而单独的 `tools/list` 就是 `tools/list`。
|
||||
|
||||
每个 span 带有几个属性:
|
||||
|
||||
* `mcp.method.name` 和 `mcp.protocol.version`,每个 span 上都有。
|
||||
* `jsonrpc.request.id`,请求上才有(通知没有)。
|
||||
* 处理函数抛出异常会把 span 状态设为 error。`is_error=True` 的工具结果也一样。
|
||||
|
||||
由于追踪工具调用是非常常见的需求,`tools/call` span 遵循 OpenTelemetry 的 [GenAI 语义约定](https://opentelemetry.io/docs/specs/semconv/gen-ai/):
|
||||
|
||||
* `gen_ai.operation.name`,设为 `"execute_tool"`。
|
||||
* `gen_ai.tool.name`,设为被调用的工具。
|
||||
|
||||
`prompts/get` span 同理带有 `gen_ai.prompt.name`。list 类方法不带 `gen_ai.*` 键,因为没有东西可命名。
|
||||
|
||||
!!! tip
|
||||
正是这些 GenAI 属性,让追踪 UI 能像对待其他任何 agent 一样对你的工具调用分组。这种分组是白送的,不需要额外代码。
|
||||
|
||||
## 想用之前零成本 {#it-costs-nothing-until-you-want-it}
|
||||
|
||||
这一点让“默认开启”成为一个让人放心的默认值。
|
||||
|
||||
SDK 只依赖 `opentelemetry-api`,也就是 OpenTelemetry 轻量的那一半。没有安装 SDK 和 exporter 时,创建 span 是空操作。所以你的服务器此刻发出的 span 几乎没有任何开销,也没有人在收集它们。
|
||||
|
||||
等到哪天想**看到**它们,就装上另一半,并把它指向某个地方:
|
||||
|
||||
```console
|
||||
uv add opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
```
|
||||
|
||||
按 OpenTelemetry 的常规方式配置一个 exporter,SDK 一直在默默创建的每个 span 就都亮起来了。服务器代码不用改,一行都不用。
|
||||
|
||||
!!! info
|
||||
[Pydantic Logfire](https://logfire.pydantic.dev/) 就是这样一个后端,而且它替你把配置做了:`pip install logfire`、`logfire.configure()`,你的 MCP span 就会出现在实时视图里。它构建在 OpenTelemetry 之上,所以下面的内容对它同样适用。
|
||||
|
||||
## 跨越线路的 trace {#traces-that-cross-the-wire}
|
||||
|
||||
trace 最有用的时候,是它能在一幅连贯的图景里跟随请求从客户端一路进入服务器。
|
||||
|
||||
当客户端和服务器都运行本 SDK 时,这种关联是自动的。客户端把 [W3C trace context](https://www.w3.org/TR/trace-context/) 注入请求,服务器再把它读出来,于是服务器 span 嵌套在同一个 trace 的客户端 span 之下。这就是 [SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414),不用开口就能得到。
|
||||
|
||||
如果入站消息没有携带 trace context,比如请求来自一个不是本 SDK 的客户端,服务器 span 就直接以服务器上当前已有的 span 为父,而不是另起一个全新的孤立 trace。
|
||||
|
||||
## 关掉它 {#turning-it-off}
|
||||
|
||||
追踪是一个中间件,排在服务器中间件列表的第一个。如果确实想要一个不发出任何 span 的服务器,把它拿掉:
|
||||
|
||||
```python
|
||||
from mcp.server._otel import OpenTelemetryMiddleware
|
||||
|
||||
mcp._lowlevel_server.middleware[:] = [
|
||||
m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware)
|
||||
]
|
||||
```
|
||||
|
||||
!!! warning
|
||||
这个导入带前导下划线,这是故意的。这个类是临时性的,和 [`Server.middleware`](../advanced/middleware.md) 一样是临时性的,所以要预期导入路径会变。你几乎永远用不到这个:没装 exporter 时 span 不花钱,所以通常的做法是让它们开着,不装 exporter 就行。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 每个 `MCPServer` 和每个低层 `Server` 默认都会为每条入站消息发出一个 `SERVER` span。你什么都不用写。
|
||||
* span 带有 `mcp.method.name` 和 `mcp.protocol.version`;`tools/call` 和 `prompts/get` 还带有 GenAI 属性,让你的工具调用像其他任何 agent 的一样分组。
|
||||
* 在安装 OpenTelemetry SDK 和 exporter 之前零成本,装上之后就会亮起来,服务器一行都不用改。
|
||||
* 两端都运行本 SDK 时,客户端到服务器的 trace context 自动传播。
|
||||
|
||||
决定一个请求到底能不能运行的,是 **[授权](authorization.md)**。
|
||||
Loading…
Reference in a new issue