docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
10 changed files with 89737 additions and 54 deletions
128
i18n/zh/pages/handlers/context.md
Normal file
128
i18n/zh/pages/handlers/context.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
translation:
|
||||
sections: [b50152f05c81e786, b302059b22fb7cb4, 85682a1bf561243a, 53fc48838eb6837a, b24190e0842786ec, 85f93e150fc9b240]
|
||||
tool: 1
|
||||
---
|
||||
# Context {#the-context}
|
||||
|
||||
工具的参数来自模型。其余的一切(正在处理的请求、所在的服务器、与客户端对话的途径)都来自同一个对象:**`Context`**。
|
||||
|
||||
你不需要构造它,也不需要配置它。只需要声明它。
|
||||
|
||||
## 声明它 {#ask-for-it}
|
||||
|
||||
给任意工具加一个用 `Context` 标注的参数:
|
||||
|
||||
```python title="server.py" hl_lines="2 8"
|
||||
--8<-- "docs_src/context/tutorial001.py"
|
||||
```
|
||||
|
||||
* SDK 为每个请求构建一个新的 `Context` 并传进来。
|
||||
* 参数**名字无关紧要**。`ctx`、`context`、`c` 都行:SDK 靠注解找到它。
|
||||
* 资源和提示词也可以用同样的方式声明一个。
|
||||
* `ctx.request_id` 是函数当前正在处理的请求的 id。
|
||||
|
||||
!!! info
|
||||
如果用过 FastAPI,这一招应该不陌生:用框架自己的类型声明一个参数(那边是 `Request`,这边是 `Context`),框架就会把它传进来。不需要注册,不需要配置:类型注解就是全部机制。
|
||||
|
||||
### 对模型不可见 {#invisible-to-the-model}
|
||||
|
||||
这一点要牢记。下面是 `tools/list` 为 `search_books` 报告的输入模式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
只有一个属性。`ctx` 不是参数:它从不出现在模式里,模型从不会得知它的存在,也没有客户端能填写它。这是你和 SDK 之间的约定,在线路上不可见。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
用 MCP Inspector 运行服务器:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
`search_books` 的表单只有一个 `query` 字段。用 `dune` 调用它:
|
||||
|
||||
```text
|
||||
[request 3] Found 3 books matching 'dune'.
|
||||
```
|
||||
|
||||
这个数字就是这次请求碰巧的编号。再调用一次工具,它就会变:每个请求都有自己的 `Context`。
|
||||
|
||||
## 它提供什么 {#what-it-gives-you}
|
||||
|
||||
注入的对象很小。除了 `request_id`:
|
||||
|
||||
* `await ctx.read_resource(uri)`:在工具内部读取服务器**自己的**资源。见下一节。
|
||||
* `await ctx.report_progress(progress, total, message)`:在长时间调用期间把进度流式发回调用方。详见 **[进度](progress.md)**。
|
||||
* `await ctx.elicit(message, schema)` 和 `await ctx.elicit_url(...)`:暂停工具,向用户提一个问题。这是 **[征询](elicitation.md)**。
|
||||
* `ctx.session`:服务器与这个客户端对话的这一端。发给客户端的通知都在这里;最后一节会用到它。
|
||||
* `ctx.headers`:传输携带的请求头,stdio 上为 `None`。用 `(ctx.headers or {}).get("x-...")` 读取自定义请求头。请求头是客户端提供的输入——用来传语言区域或功能开关没问题,但绝不能用作身份。
|
||||
* `ctx.request_context`:原始的每请求记录。你会用到的字段是 `lifespan_context`,也就是启动代码 yield 出来的对象(见 **[生命周期](lifespan.md)**)。
|
||||
|
||||
日志有意不在这个列表里。服务器用 Python 的 `logging` 模块记录日志,和任何其他 Python 程序一样。**[日志](logging.md)** 这一页简短地解释了原因。
|
||||
|
||||
!!! tip
|
||||
注入只发生在你注册的那个函数上。工具调用的辅助函数不会得到自己的 `Context`;把 `ctx` 当作普通参数传下去。不存在可以从别处获取的环境“当前上下文”。
|
||||
|
||||
## 读取自己的资源 {#read-your-own-resources}
|
||||
|
||||
服务器的资源不只是给客户端用的。工具也可以读取它们:
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/context/tutorial002.py"
|
||||
```
|
||||
|
||||
`ctx.read_resource` 通过为 `resources/read` 提供服务的同一个注册表解析 URI,所以工具拿到的和客户端拿到的一样:一个 `ReadResourceContents` 的可迭代对象,每个内容块一个。这个 URI 只有一个:
|
||||
|
||||
```python
|
||||
contents.content # 'fiction, non-fiction, poetry'
|
||||
contents.mime_type # 'text/plain'
|
||||
```
|
||||
|
||||
* `content` 正是 `genres()` 返回的内容。单一事实来源:客户端浏览资源,你的工具消费它,没人复制字符串。
|
||||
* `describe_catalog` 唯一的参数是 `Context`,所以它的输入模式**完全没有属性**。模型用 `{}` 调用它。
|
||||
|
||||
## 告诉客户端列表变了 {#tell-the-client-the-list-changed}
|
||||
|
||||
服务器提供的内容并不是在导入时就固定的。在运行时注册一个工具,然后告诉客户端:
|
||||
|
||||
```python title="server.py" hl_lines="15-16"
|
||||
--8<-- "docs_src/context/tutorial003.py"
|
||||
```
|
||||
|
||||
* `mcp.add_tool(recommend_book)` 把一个普通函数注册为工具:名称、描述和模式的推导方式与 `@mcp.tool()` 完全一致。
|
||||
* `await ctx.session.send_tool_list_changed()` 发送 `notifications/tools/list_changed`。收到它的客户端会再次调用 `tools/list`,并看到 `recommend_book`。
|
||||
|
||||
同类方法还有 `send_resource_list_changed()`、`send_prompt_list_changed()`,以及针对某个特定资源变化的 `send_resource_updated(uri)`。
|
||||
|
||||
在 2026-07-28 连接上,客户端只在自己打开的 `subscriptions/listen` 流上接收变更通知,所以上面的 `send_*` 方法到不了这些流。`Context` 的发布方法会一次性投递到所有已订阅的流:`await ctx.notify_tools_changed()`、`await ctx.notify_prompts_changed()`、`await ctx.notify_resources_changed()` 和 `await ctx.notify_resource_updated(uri)`。完整说明,包括跨副本横向扩展,详见 **[订阅](subscriptions.md)**。
|
||||
|
||||
!!! check
|
||||
在有人运行 `enable_recommendations` 之前,你承诺的那个工具并不存在。照样调用它,结果是一条模型能读懂的错误:
|
||||
|
||||
```text
|
||||
Unknown tool: recommend_book
|
||||
```
|
||||
|
||||
运行 `enable_recommendations`,同样的调用就会成功。工具列表是真正动态的:`tools/list` 反映的是**此刻**注册了什么。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 用 `Context` 标注一个参数(在工具、资源或提示词里),SDK 就会注入它。名字随你定。
|
||||
* 它对模型不可见:输入模式永远只包含你真正的参数。
|
||||
* `ctx.request_id` 标识请求;`ctx.request_context.lifespan_context` 是启动代码 yield 出来的对象。
|
||||
* `await ctx.read_resource(uri)` 让工具读取服务器自己的资源。
|
||||
* `ctx.session` 是回到客户端的通道:`send_tool_list_changed()` 及其同类方法告诉客户端重新获取你改动过的列表。
|
||||
* 进度报告和征询同样从 `Context` 开始;它们各有自己的页面。
|
||||
|
||||
模型永远看不到、由你自己的函数填充的参数,就是 **[依赖](dependencies.md)**。
|
||||
137
i18n/zh/pages/handlers/dependencies.md
Normal file
137
i18n/zh/pages/handlers/dependencies.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
---
|
||||
translation:
|
||||
sections: [b0389403e98d25ad, e2cf58b43b285e86, a363e1a38e1a5971, 6cfac078feb18013, b4535bd61df337e6, e97ed44207f929fd]
|
||||
tool: 1
|
||||
---
|
||||
# 依赖 {#dependencies}
|
||||
|
||||
工具的参数来自模型。但有些值绝不该由模型提供:从你的记录里查出来的价格、只有人才能给出的确认,以及任何模型一旦凭空编造就会出错的东西。
|
||||
|
||||
**依赖**是由你自己的函数填充的参数。给参数加上注解,指明函数,SDK 就会在工具运行之前调用它。
|
||||
|
||||
## 声明一个依赖 {#declare-one}
|
||||
|
||||
把参数类型包进 `Annotated[...]`,再加上 `Resolve(fn)`:
|
||||
|
||||
```python title="server.py" hl_lines="18-19 23"
|
||||
--8<-- "docs_src/dependencies/tutorial001.py"
|
||||
```
|
||||
|
||||
* `check_stock` 是一个**解析器**(resolver):一个普通函数,SDK 在 `reserve_book` 之前运行它,它的返回值就成了 `stock` 参数。
|
||||
* 它的 `title` 参数就是工具自己的 `title` 参数,**按名称**匹配。解析器看到的值和工具函数体看到的一模一样,都是校验过的值。
|
||||
* 工具函数体一开始就拿到一个现成的 `Stock`。工具里没有查询代码,也没有“万一查不到怎么办”的铺垫。
|
||||
|
||||
!!! info
|
||||
如果用过 FastAPI,这就是 `Depends`。同样的做法,同样的理由:函数声明自己需要什么,框架负责提供,接线逻辑放在类型注解里。
|
||||
|
||||
### 对模型不可见 {#invisible-to-the-model}
|
||||
|
||||
这是 `tools/list` 为 `reserve_book` 报告的输入模式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"title": "Title", "type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"title": "reserve_bookArguments"
|
||||
}
|
||||
```
|
||||
|
||||
只有一个属性。和 **[Context](context.md)** 里的 `Context` 一样,被解析的参数是你和 SDK 之间的约定:`stock` 不在模式里,模型从不会知道它的存在,客户端即便硬塞一个 `stock` 值过来也会被忽略。工具能收到的只有解析器给出的值。
|
||||
|
||||
最后这一点才是关键。模型无法提供的参数,就是模型无法弄错的参数。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
用 MCP Inspector 运行服务器:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
`reserve_book` 的表单只有一个 `title` 字段,哪儿都找不到 `stock`。用 `Dune` 调用它:
|
||||
|
||||
```text
|
||||
Reserved 'Dune' (6 copies left).
|
||||
```
|
||||
|
||||
工具函数体什么都没查:`check_stock` 先运行,它返回的 `Stock` 作为参数传了进来。换成 `Neuromancer` 试试,同一个解析器会给工具一个零。
|
||||
|
||||
!!! tip
|
||||
你当然可以直接在工具函数体里调用 `check_stock(title)`。如果这个值不只是一次辅助函数调用那么简单,就把它声明为依赖:每个需要库存的工具都声明同一个参数,而不管有多少个工具声明它,SDK 每次调用最多只运行一次解析器。后面几节补上其余内容:相互依赖的解析器,以及会去问用户的解析器。
|
||||
|
||||
## 依赖的依赖 {#dependencies-of-dependencies}
|
||||
|
||||
解析器可以用同样的注解声明自己的依赖:
|
||||
|
||||
```python title="server.py" hl_lines="22 29-30"
|
||||
--8<-- "docs_src/dependencies/tutorial002.py"
|
||||
```
|
||||
|
||||
* `estimate_delivery` 依赖 `check_stock`。SDK 按顺序运行这张图:先查库存,再算预估,最后才是工具。
|
||||
* `stock` 和 `delivery` 最终都需要 `check_stock`,但它**每次调用只运行一次**。一次库存查询,两个使用者。
|
||||
* 不需要注册任何东西。注解**本身就是**这张图。
|
||||
|
||||
!!! check
|
||||
别轻信“每次调用一次”这句话。在 `check_stock` 里放一个 `print`,然后从 Inspector 调用 `order_book`:每次调用打印一行。两个使用者,一次查询。
|
||||
|
||||
SDK 在工具注册时分析这张图,而不是在调用时。遇到它无法归类的参数——既不是 `Context`,也不是 `Resolve(...)`,也不是某个工具参数的名字——或者解析器之间出现环,都会在启动时抛出 `InvalidSignature`。服务器在任何客户端连上来之前就会失败,错误里会点名出问题的参数或解析器。
|
||||
|
||||
解析器的参数和工具的参数按完全相同的方式解析:另一个 `Resolve(...)`、按名称匹配的工具自身参数,或者 `Context`——`ctx.headers`、生命周期对象,全都可以。
|
||||
|
||||
!!! warning
|
||||
在 HTTP 传输上,`Context` 包含 `ctx.headers`。请求头是**客户端提供的输入**,和任何工具参数一样:用来传区域设置或功能开关没问题,但绝不能当作身份。调用者是谁由你的授权层决定(**[授权](../run/authorization.md)**),而不是一个谁都能设置的请求头。
|
||||
|
||||
!!! tip
|
||||
“每次调用一次”就是字面意思:下一次 `tools/call` 会再次运行 `check_stock`。需要比单次请求活得更久的资源——数据库连接池、HTTP 客户端——应该放在**[生命周期](lifespan.md)**里,解析器可以通过 `ctx.request_context.lifespan_context` 拿到它。
|
||||
|
||||
## 必要时才问 {#ask-when-you-must}
|
||||
|
||||
解析器不一定要知道答案。它可以返回 `Elicit(message, Model)`,SDK 会去问用户——也就是**[征询](elicitation.md)**(elicitation)机制,由 SDK 替你运行:
|
||||
|
||||
```python title="server.py" hl_lines="26-32 39"
|
||||
--8<-- "docs_src/dependencies/tutorial003.py"
|
||||
```
|
||||
|
||||
* 有货:`confirm_backorder` 直接返回一个 `Backorder`。**不提问,不往返。**只有当用户的回答真正有用时才会打扰他们。
|
||||
* 缺货:SDK 发出征询,按 `Backorder` 校验回答,然后注入。解析器完全不碰协议。
|
||||
* 工具像读取其他参数一样读取 `backorder.confirm`。回答**否**也算回答:征询以 `confirm=False` 被接受,工具照常运行,但不会下单。提问变成了前置条件,而不是塞在工具函数体里的管道代码。
|
||||
|
||||
那如果用户干脆不回答——拒绝这个问题,或者取消它呢?
|
||||
|
||||
!!! check
|
||||
对 `Neuromancer` 运行 `order_book` 并拒绝回答。注解写成 `Annotated[Backorder, Resolve(...)]` 时,工具函数体根本不会运行;调用失败并返回一个模型能读懂的错误结果:
|
||||
|
||||
```text
|
||||
Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline
|
||||
```
|
||||
|
||||
对前置条件来说这是正确的默认行为:没有回答,就没有订单。如果拒绝是工具想要自行处理的一种结果——跳过缺货预订,但仍然推荐另一本书——就改为注解 `ElicitationResult[Backorder]`,工具会收到完整的接受/拒绝/取消结果并据此分支。**[征询](elicitation.md)**展示了这种写法,以及关于提问的其他一切:模式规则、三种回答、客户端一侧的对话。
|
||||
|
||||
!!! info
|
||||
框架根据协商出的协议版本选择问题走哪种传输方式;上面的代码在两种情况下完全相同。在 **2026-07-28** 及之后,问题搭载在一次多轮往返(multi-round-trip)的 `tools/call` 里——服务器返回问题,客户端的 `elicitation_callback` 回答它,`Client` 替你重试调用(**[多轮往返请求](multi-round-trip.md)**)。在 **2025-11-25** 及之前,它是调用中途的一次同步征询请求。每个问题在每次调用中恰好被问一次——这是对问题的保证,而不是对解析器的保证。在多轮往返形式下,每当调用在一个问题之后恢复,任何解析器都可能再次运行,所以 `return Elicit(...)` 之前的代码在每一轮都会执行;随后记录下的回答会满足重复出现的问题,而不会再次打扰用户。只有当解析器提问时才会去查记录下的回答;像 `check_stock` 这样**不**提问就给出答案的解析器,永远提供它自己算出来的值。因为每个回答都要匹配回它的问题,会发起征询的解析器必须根据工具的参数和先前的回答确定性地推导出问题。每次调用生成的值(`default_factory` 生成的 id、时间戳)在每一轮都会重新推导,绝不能出现在需要绑定回答的问题里。用这种易变数据构造的问题会让每个记录下的回答看起来都已过期,于是服务器每一轮都会重新提问,直到客户端的轮数上限终止这次调用。
|
||||
|
||||
## 问客户端,而不是用户 {#ask-the-client-not-the-user}
|
||||
|
||||
征询是解析器能问的三种问题之一,多轮往返流程不允许其他问题。另外两种问的是**客户端**而不是用户:返回 `Sample(...)` 通过客户端发起一次 LLM 调用(一个 `sampling/createMessage` 请求),或者返回 `ListRoots()` 获取客户端当前的根目录(roots)。这两者都没有接受/拒绝的结果;使用者直接注解结果类型,`CreateMessageResult`(请求带有 `tools` 或 `tool_choice` 时为 `CreateMessageResultWithTools`)或 `ListRootsResult`:
|
||||
|
||||
```python title="server.py" hl_lines="10-15 21"
|
||||
--8<-- "docs_src/dependencies/tutorial004.py"
|
||||
```
|
||||
|
||||
* 框架对它们的路由方式和 `Elicit` 完全一样:在 **2026-07-28** 上走多轮往返的 `tools/call`,在 **2025-11-25** 上走独立的服务器->客户端请求。未声明的能力会以 `-32021` 协议错误拒绝调用(`sampling`、`roots`、表单模式的 `elicitation`;请求带有 `tools` 或 `tool_choice` 时为 `sampling.tools`)。
|
||||
* 上面 info 框里关于问题的所有内容原样适用:`Sample` 请求按其精确的渲染结果匹配到记录下的结果,所以要根据工具的参数和先前的回答确定性地构造它;这样客户端为 LLM 调用付出的代价是每次工具调用一次,而不是每轮一次。记录下的结果在本次调用剩余时间里都搭载在 `request_state` 上,所以一个非常大的补全会让后面每次往返都更重。
|
||||
* 独立的采样(sampling)和根目录**功能**在 2026-07-28 已弃用(SEP-2577)。需要客户端模型的新服务器应通过这个载体提问;不需要的服务器应直接对接 LLM 提供商。`include_context` 取 `"none"` 以外的值本身也已弃用,不要用。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 在工具参数上写 `Annotated[T, Resolve(fn)]`:SDK 运行 `fn` 并注入它的返回值。
|
||||
* 被解析的参数对模型不可见,客户端也无法提供。模型绝不能编造的值——价格、身份、权限——就该放在这里。
|
||||
* 解析器的参数按同样的方式解析:`Context`、另一个 `Resolve(...)`,或按名称匹配的工具参数。不管有多少使用者,这张图每一轮最多运行每个解析器一次;每个问题恰好问一次,而调用在一个问题之后恢复时,任何解析器都可能再次运行。
|
||||
* 有问题的图在注册时就以 `InvalidSignature` 失败,而不是在调用中途。
|
||||
* 返回 `Elicit(message, Model)` 去问用户,只在必要时才问。未包装的注解在拒绝时中止;`ElicitationResult[T]` 让工具自行分支。
|
||||
* 返回 `Sample(...)` 或 `ListRoots()` 向客户端要一次 LLM 补全或根目录列表;注入的是原始结果。
|
||||
|
||||
服务器在启动时一次性构建的状态,以及处理函数如何拿到它,见 **[生命周期](lifespan.md)** 页面。
|
||||
175
i18n/zh/pages/handlers/elicitation.md
Normal file
175
i18n/zh/pages/handlers/elicitation.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
translation:
|
||||
sections: [335ca2a0b266f003, d1ad562d3fe87bc0, 0bb1396c86daeba4, d1cb1235bb9ee267, 833179c09d239c83, e5d6dec2d2e655e8]
|
||||
tool: 1
|
||||
---
|
||||
# 征询 {#elicitation}
|
||||
|
||||
一个工具活干到一半、只差一个答案,不必因此失败。
|
||||
|
||||
**征询**(elicitation)让它可以开口问。在一次工具调用的中途,用户会收到一个问题,他们的回答会回到同一次函数调用里。
|
||||
|
||||
有两种模式:
|
||||
|
||||
* **表单模式**:你需要一个值(一次确认、一个日期、一个数量)。你描述字段,客户端渲染表单。
|
||||
* **URL 模式**:你需要用户去别的地方(OAuth 授权页面、支付页面)。他们在那里做的任何事都不经过协议。
|
||||
|
||||
提问的方式也有两种。首选的是**解析器**:把问题挂在一个参数上,SDK 负责去问——在任何连接上都行,不管客户端说的是哪个时代的协议。直接的方式是 `await ctx.elicit(...)`,它是一个从**服务器**发往**客户端**的请求,而这条通道只对处于旧版连接(规范版本 2025-11-25 或更早)的客户端存在。本页两种都讲,先从解析器开始。
|
||||
|
||||
## 用解析器提问 {#ask-with-a-resolver}
|
||||
|
||||
一个把关整个工具的问题——“确定吗?三个匹配的账户里选哪个?”——可以从工具函数体里提出来放进**解析器**,由框架替你去问。
|
||||
|
||||
标注为 `Annotated[T, Resolve(fn)]` 的参数,会在工具函数体执行之前通过运行 `fn` 来填充。解析器已经知道值时直接返回它;否则返回 `Elicit(...)`,让框架去问:
|
||||
|
||||
```python title="server.py" hl_lines="24-30 35-36"
|
||||
--8<-- "docs_src/elicitation/tutorial004.py"
|
||||
```
|
||||
|
||||
* `confirm_delete` 按名字读取工具自己的 `path` 参数,列出文件夹内容,并且**只在必须时才征询**——空文件夹直接解析为 `Confirm(ok=True)`,不需要和客户端往返。
|
||||
* `delete_folder` 标注的是 `ElicitationResult[Confirm]`,所以框架注入完整的结果,工具用 `match` 处理每一种情况:接受并确认、接受但保留(`ok=False`)、拒绝、取消。
|
||||
* `confirm` 参数永远不会出现在工具的输入模式里——客户端提供 `path`,解析器提供 `confirm`。
|
||||
|
||||
如果工具不需要分支,就改为标注解包后的模型(`Annotated[Confirm, Resolve(confirm_delete)]`):接受时它收到模型,拒绝或取消时调用以错误中止。
|
||||
|
||||
解析器在**每一种**连接上都能工作。对旧版连接上的客户端,SDK 直接把问题发给它;在 **2026-07-28** 连接上,SDK 把问题从这次调用里**返回**出去,客户端的下一次尝试会带上答案。你的解析器感觉不到区别;底层发生的事情是 **[多轮往返请求](multi-round-trip.md)**(multi-round-trip)。
|
||||
|
||||
提问只是解析器能做的事情之一。通用机制——不提问直接算出值的依赖、依赖的依赖、模型能提供什么不能提供什么——见 **[依赖](dependencies.md)** 页面。
|
||||
|
||||
## 在工具内部提问 {#ask-from-inside-the-tool}
|
||||
|
||||
工具也可以在自己的函数体中途停下来提问。
|
||||
|
||||
!!! warning
|
||||
`ctx.elicit()` 和 `ctx.elicit_url()` 是从**服务器**发往**客户端**的请求——这条通道只对处于旧版连接(规范版本 **2025-11-25** 或更早)的客户端存在。在 **2026-07-28** 连接上没有服务器发起的请求,所以这些调用会失败。解析器在两者上都能用。详见 **[协议版本](../protocol-versions.md)**。
|
||||
|
||||
`await ctx.elicit()` 接受一条消息和一个 Pydantic 模型:
|
||||
|
||||
```python title="server.py" hl_lines="9-11 20-23 25"
|
||||
--8<-- "docs_src/elicitation/tutorial001.py"
|
||||
```
|
||||
|
||||
* **`Context`** 参数就是提供 `ctx.elicit` 的东西;任何工具都可以接收一个。这个对象有自己的页面:**[Context](context.md)**。
|
||||
* `AlternativeDate` 是你想要的答案的**模式**。
|
||||
* 工具是 `async def`。必须是:它会在中途停下来等一个人。
|
||||
* 其他任何日期,工具直接返回。只在必须时才问。
|
||||
* 用户接受的日期会重新走一遍 `book_table` 本身。答案和其他输入一样是输入:如果替代日期也订满了,会再问一次,而不是盲目确认。
|
||||
|
||||
### 客户端收到什么 {#what-the-client-receives}
|
||||
|
||||
客户端拿到你的消息,旁边还有一个由模型生成的 JSON Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"accept_alternative": {
|
||||
"description": "Try another date?",
|
||||
"title": "Accept Alternative",
|
||||
"type": "boolean"
|
||||
},
|
||||
"date": {
|
||||
"default": "2025-12-26",
|
||||
"description": "Alternative date (YYYY-MM-DD)",
|
||||
"title": "Date",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["accept_alternative"],
|
||||
"title": "AlternativeDate",
|
||||
"type": "object"
|
||||
}
|
||||
```
|
||||
|
||||
这个模式就是表单。`Field(description=...)` 是标签;默认值会预填输入框,并让该字段变成可选。这和 **[工具](../servers/tools.md)** 里描述的工具参数用的是同一套 Pydantic 转 JSON Schema 的机制。
|
||||
|
||||
!!! warning
|
||||
征询的模式不如工具的输入模式表达力强。只能是扁平的原始类型字段:`str`、`int`、`float`、`bool`,或字符串的 `Literal`(会变成 `enum`)。在模型里再放一个模型,`ctx.elicit` 会在任何东西发给客户端之前抛出异常:
|
||||
|
||||
```text
|
||||
TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition
|
||||
```
|
||||
|
||||
你是在打断一个正在做事的人。如果答案需要嵌套,它本该是工具的参数。
|
||||
|
||||
### 三种答案 {#the-three-answers}
|
||||
|
||||
`result.action` 告诉你用户做了什么,恰好只有三种可能:
|
||||
|
||||
* `"accept"`:他们提交了表单。`result.data` 是一个 `AlternativeDate` 实例,已经验证过。
|
||||
* `"decline"`:他们说了不。
|
||||
* `"cancel"`:他们没做选择就关掉了问题。
|
||||
|
||||
`result.data` 只在 `"accept"` 时存在,这就是示例先检查 `result.action` 的原因。类型检查器会强制这个顺序:在 `result.action == "accept"` 之后,`result.data` 是 `AlternativeDate`;在那之前根本没有 `.data`。
|
||||
|
||||
拒绝不是错误。由工具决定拒绝意味着什么(这里是不订位),然后正常回答模型。
|
||||
|
||||
!!! tip
|
||||
答案在你的代码看到之前就已按你的模型验证过。一个给 `bool` 字段发来 `"maybe"` 的客户端不会弄坏你的订位:调用以模式不匹配的错误失败,你的 `if` 根本不会执行。
|
||||
|
||||
## 把用户引到一个 URL {#send-the-user-to-a-url}
|
||||
|
||||
有些东西绝不能经过模型或客户端:凭据、卡号、OAuth 授权。对这些,你不索要数据,而是请用户去一个地方:
|
||||
|
||||
```python title="server.py" hl_lines="10-14 23"
|
||||
--8<-- "docs_src/elicitation/tutorial002.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit_url()` 接受消息、要访问的 **URL**,以及一个你自己选的 `elicitation_id`:任何能在你的服务器内标识这次征询的字符串。
|
||||
* 结果只有一个 action,别无其他。`"accept"` 表示用户同意打开这个 URL,**不是**表示他们完成了另一头的事情。
|
||||
* 支付在带外进行,发生在用户的浏览器和你的支付提供商之间。没有任何内容会通过 MCP 回来。
|
||||
|
||||
看第二个工具。当你的服务器得知带外流程结束了(一个 webhook、一次轮询;这里建模成第二个工具),`ctx.session.send_elicit_complete(...)` 会用同一个 `elicitation_id` 发送 `notifications/elicitation/complete`。客户端就是这样知道可以不再显示“waiting for payment...”的。没有它,客户端只能猜。
|
||||
|
||||
## 客户端一侧 {#the-client-side}
|
||||
|
||||
服务器提问。客户端通过给 `Client(...)` 传一个 **`elicitation_callback`** 来回答:
|
||||
|
||||
```python title="client.py" hl_lines="6-7 18"
|
||||
--8<-- "docs_src/elicitation/tutorial003.py"
|
||||
```
|
||||
|
||||
* 一个回调处理两种模式。`params` 是 `ElicitRequestFormParams` 和 `ElicitRequestURLParams` 的联合类型;用 `isinstance` 分支。
|
||||
* 对 URL,把 `params.url` 展示给用户,返回他们选的 action。永远不带任何 `content`。
|
||||
* 对表单,真实的应用会渲染 `params.requested_schema`,把用户的输入作为 `content` 返回。这个回调总是用一个固定答案说“是”,这正是测试里想要的回调。
|
||||
* 传入回调同时也是**能力声明**:服务器就是这样得知这个客户端可以被提问。客户端能替服务器回答的其他东西在 **[客户端回调](../client/callbacks.md)**。
|
||||
|
||||
!!! info
|
||||
征询是从**服务器**发往**客户端**的请求,而这类请求只存在于经典握手的会话上,这就是这个客户端传 `mode="legacy"` 的原因。在 **2026-07-28** 连接上,工具改为把问题从调用里**返回**出去来提问;那个流程见 **[多轮往返请求](multi-round-trip.md)**。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
用 Streamable HTTP 启动 `ctx.elicit` 表单模式的 `server.py`(`book_table` 那个)(那条一行命令见 **[运行服务器](../run/index.md)**),然后运行客户端的 `main()`,向 `book_table` 要圣诞节当天的位子。
|
||||
|
||||
回调会打印它收到的问题:
|
||||
|
||||
```text
|
||||
No tables for 2 on 2025-12-25. Would you like to try another date?
|
||||
```
|
||||
|
||||
它回答 `{"accept_alternative": True, "date": "2025-12-27"}`,而一直在 `await ctx.elicit(...)` 里等着的工具完成订位:
|
||||
|
||||
```text
|
||||
Booked a table for 2 on 2025-12-27.
|
||||
```
|
||||
|
||||
现在换成 URL 模式的 `server.py`,让同一个 `main()` 去调 `pay_deposit`:同一个回调走另一条分支,打印支付链接,工具返回“Complete the payment in your browser.”。一次往返,调用中途,双向都有。
|
||||
|
||||
!!! check
|
||||
现在从 `Client` 里去掉 `elicitation_callback=`,再为圣诞节当天调一次 `book_table`。整个调用以协议错误失败:
|
||||
|
||||
```text
|
||||
Elicitation not supported
|
||||
```
|
||||
|
||||
没注册回调的客户端从未声明 `elicitation` 能力,所以没人可问。你的工具收到的不是 `"decline"`,而是一个异常。要为此设计:每一次征询都需要对“要是问不了怎么办?”有一个合理的答案。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 标注为 `Annotated[T, Resolve(fn)]` 的参数由解析器填充,解析器需要提问时返回 `Elicit(...)`。它在每种连接上都能用。
|
||||
* 模式是一个扁平的 Pydantic 模型:只能有原始类型字段,回来时会验证。
|
||||
* `result.action` 是 `"accept"`、`"decline"` 或 `"cancel"`;`result.data` 只在 accept 时存在。
|
||||
* `await ctx.elicit(message, schema=Model)` 在工具函数体内部提问,`await ctx.elicit_url(message, url, elicitation_id)` 用于一切绝不能经过模型的东西(`ctx.session.send_elicit_complete(elicitation_id)` 表示带外部分已完成)。两者都是服务器到客户端的请求:需要客户端处于旧版连接。
|
||||
* 客户端用一个 `elicitation_callback` 回答,按 params 类型分支;注册它就是声明能力。
|
||||
* 在 2026-07-28 连接上,服务器返回问题而不是推送问题;同一个回调的输入来自 **[多轮往返请求](multi-round-trip.md)**。
|
||||
|
||||
那次返回之下的一切(重试循环、保护 `requestState`、自己驱动它)见 **[多轮往返请求](multi-round-trip.md)**。
|
||||
24
i18n/zh/pages/handlers/index.md
Normal file
24
i18n/zh/pages/handlers/index.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
translation:
|
||||
sections: [424930166c4bc6f3]
|
||||
tool: 1
|
||||
---
|
||||
# 在处理函数内部 {#inside-your-handler}
|
||||
|
||||
处理函数的参数来自客户端。除此之外它能读到的**其他**一切,以及它运行期间能做的一切,都在这里。
|
||||
|
||||
它能读到什么:
|
||||
|
||||
* **[Context](context.md)** 是任何处理函数都可以额外要求的那一个参数:当前请求、它的标头、它的会话,以及进度和变更通知这些动作。
|
||||
* **[依赖](dependencies.md)** 是模型永远看不到的参数,由你自己的函数通过 `Resolve` 填入。
|
||||
* **[生命周期](lifespan.md)** 讲的是服务器在启动时只构建一次的状态,以及处理函数如何通过 `Context` 拿到它。
|
||||
|
||||
它运行期间能做什么:
|
||||
|
||||
* 用 **[征询(elicitation)](elicitation.md)** 向用户请求更多输入,以及承载它的 2026-07-28 模式 **[多轮往返请求](multi-round-trip.md)**(multi-round-trip)。
|
||||
* 用 **[采样(sampling)与根目录(roots)](sampling-and-roots.md)** 向客户端请求一次 LLM 补全或它的工作区文件夹——已弃用,但仍然提供。
|
||||
* 对耗时的操作报告 **[进度](progress.md)**。
|
||||
* 用 **[日志](logging.md)** 写日志(写到标准错误,给运维服务器的人看)。
|
||||
* 用 **[订阅](subscriptions.md)** 告诉已订阅的客户端有东西变了。
|
||||
|
||||
如果还没注册过处理函数,先看 **[工具](../servers/tools.md)**。这里的每一页都假设你已经有一个了。
|
||||
101
i18n/zh/pages/handlers/lifespan.md
Normal file
101
i18n/zh/pages/handlers/lifespan.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
translation:
|
||||
sections: [f3ca8ac5f90f2dfa, 85a1ef3588ba0736, 563346d4d5804933, 9e3528340d0bab53]
|
||||
tool: 1
|
||||
---
|
||||
# 生命周期 {#lifespan}
|
||||
|
||||
大多数真实的服务器在整个运行期间都会持有某样东西:数据库连接池、HTTP 客户端、加载好的模型。
|
||||
|
||||
你不想每次调用都重新构建它,又希望能干净地关闭它。这就是**生命周期(lifespan)**的用途。
|
||||
|
||||
## 带类型的生命周期 {#a-typed-lifespan}
|
||||
|
||||
生命周期是一个 `@asynccontextmanager`,它接收服务器并 `yield` **一个对象**。无论 yield 出什么,只要服务器在运行,每个处理函数都能用到它。
|
||||
|
||||
```python title="server.py" hl_lines="25-31 34 38 40"
|
||||
--8<-- "docs_src/lifespan/tutorial001.py"
|
||||
```
|
||||
|
||||
从下往上读:
|
||||
|
||||
* `app_lifespan` 在 `yield` **之前**连接 `Database`,并在**之后**的 `finally` 里断开连接。这就是启动和关闭。
|
||||
* 它 yield 一个 `AppContext`,一个普通的 dataclass,装着你准备好的东西。今天是一个字段,明天可能是十个。
|
||||
* `MCPServer("Bookshop", lifespan=app_lifespan)` 就是全部的接线。
|
||||
* 在工具内部,yield 出的对象是 `ctx.request_context.lifespan_context`。
|
||||
|
||||
生命周期只运行**一次**。服务器启动时(第一个请求之前)进入,服务器停止时退出。其间的每个请求共享同一个 `AppContext`。
|
||||
|
||||
!!! info
|
||||
如果你写过 FastAPI 的 `lifespan`,这些你已经会了。同样的装饰器,同样的 `yield`,同样的 `finally`。
|
||||
|
||||
### 模型看到什么 {#what-the-model-sees}
|
||||
|
||||
没有新东西。`ctx` 是一个 **Context** 参数,所以 SDK 会注入它,它永远不会进入输入模式:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"genre": {"title": "Genre", "type": "string"}
|
||||
},
|
||||
"required": ["genre"],
|
||||
"title": "count_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
`genre` 是模型唯一能传入的参数。生命周期是服务器自己的事。
|
||||
|
||||
`@mcp.resource()` 和 `@mcp.prompt()` 函数也可以接收 `ctx` 参数,只是要写成裸的 `Context`,原因下一节会讲到。`ctx` 携带的所有内容详见 **[Context](context.md)**。
|
||||
|
||||
### 它确实带类型 {#it-really-is-typed}
|
||||
|
||||
再看一眼那个注解:`ctx: Context[AppContext]`。
|
||||
|
||||
正是这一个类型参数,让 `ctx.request_context.lifespan_context` 在类型检查器眼里**就是**一个 `AppContext`。`.db` 能自动补全;`.dbb` 在你运行服务器之前就会报错。
|
||||
|
||||
如果改写成裸的 `Context`,`lifespan_context` 的类型就是 `dict[str, Any]`:类型检查器无从知道你的生命周期 yield 了什么。运行时对象还在,只是失去了类型上的帮助。
|
||||
|
||||
!!! warning
|
||||
`Context[AppContext]` 是**仅限工具**的写法。把它放在 `@mcp.resource()` 或 `@mcp.prompt()` 函数上,对该处理函数的每次调用都会失败。客户端会收到一个错误,服务器日志会说明原因:
|
||||
|
||||
```text
|
||||
Context is not available outside of a request
|
||||
```
|
||||
|
||||
在资源和提示词里,写裸的 `ctx: Context`。生命周期 yield 出的对象在运行时仍然是 `ctx.request_context.lifespan_context`;你放弃的是类型参数,不是对象。
|
||||
|
||||
!!! tip
|
||||
生命周期总是存在。如果你不传,SDK 的默认实现会 yield 一个空 `dict`,所以 `ctx.request_context.lifespan_context` 是 `{}`,绝不会是 `None`。也正是因为这个默认值,裸的 `Context` 才把它的类型定为 `dict[str, Any]`。
|
||||
|
||||
## 亲眼看它发生 {#watch-it-happen}
|
||||
|
||||
“启动在第一个请求之前运行”这种话,不该只凭信任接受。
|
||||
|
||||
把服务器精简到只剩生命周期:给 `Database` 加一个 `connected` 标志,在 `connect()` 和 `disconnect()` 里翻转它,再加一个报告它的工具。
|
||||
|
||||
```python title="server.py" hl_lines="11 14 17 25 44"
|
||||
--8<-- "docs_src/lifespan/tutorial002.py"
|
||||
```
|
||||
|
||||
`database` 放在模块级别只有一个原因:这样就能从服务器**外部**观察它。
|
||||
|
||||
!!! check
|
||||
三个时刻,三个值:
|
||||
|
||||
* 服务器启动前,`database.connected` 是 `False`。导入模块什么也没连接。
|
||||
* 运行期间,调用 `database_status`,结果是 `"connected"`。
|
||||
* 停止服务器,`finally` 块运行:`database.connected` 又变回 `False`。
|
||||
|
||||
工作恰好发生在你放的位置:围绕 `yield`,不在导入时,也不是每个请求一次。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `lifespan=` 接收一个 `@asynccontextmanager`,它接收服务器并 `yield` 一个对象。
|
||||
* `yield` 之前的代码是启动。之后的 `finally` 是关闭。
|
||||
* 它只运行一次,围绕服务器的整个生命,而不是每个请求一次。
|
||||
* 无论 `yield` 出什么,它在每个工具、资源和提示词里都是 `ctx.request_context.lifespan_context`。
|
||||
* `ctx: Context[AppContext]` 让这种访问在工具里完全带类型。资源和提示词用裸的 `Context`。
|
||||
* 不传 `lifespan=` 意味着一个空 `dict`,绝不会是 `None`。
|
||||
|
||||
在调用中途停下来,向用户询问只有他们知道的事情的处理函数,详见 **[征询(elicitation)](elicitation.md)**。
|
||||
79
i18n/zh/pages/handlers/logging.md
Normal file
79
i18n/zh/pages/handlers/logging.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
translation:
|
||||
sections: [c93a3e1aefd77955, 7851abd5ec54393b, f49d1ca2f330f9cd, c03764bd9dfeef7b, 4a0391691a674ae4, 2df5cd279eabf9f5]
|
||||
tool: 1
|
||||
---
|
||||
# 日志 {#logging}
|
||||
|
||||
在工具里记录日志,和在其他任何 Python 函数里一样:用标准库。
|
||||
|
||||
MCP 在协议层面有一个**日志能力**(logging capability):服务器可以通过 `Context` 对象上的方法,把自己的日志消息作为通知推送给客户端。规范的 2026-07-28 修订版**弃用了这个能力,而且没有提供替代方案**,所以本文档不讲它。哪些内容已弃用、该用什么代替,完整清单见 **[已弃用的功能](../deprecated.md)**。
|
||||
|
||||
取而代之的做法,就是你在其他所有 Python 程序里的做法:标准库。
|
||||
|
||||
## 一个会记录日志的工具 {#a-tool-that-logs}
|
||||
|
||||
```python title="server.py" hl_lines="1 5 13"
|
||||
--8<-- "docs_src/logging/tutorial001.py"
|
||||
```
|
||||
|
||||
* `logging.getLogger(__name__)` 返回一个以模块名命名的 logger。在文件顶部创建一次即可。
|
||||
* 在工具内部调用 `logger.info(...)`,和在其他任何函数里一样。不用注入什么,不用 `await` 什么,也没有任何 MCP 特有的东西。
|
||||
|
||||
!!! check
|
||||
调用这个工具,看看完整的结果:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
|
||||
result.structured_content # {'result': "Found 3 books matching 'dune'."}
|
||||
```
|
||||
|
||||
里面哪儿都没有那行日志。日志是给**你**——运维这个服务器的人——看的。模型永远看不到它。如果某些内容应该让模型读到,就 `return` 它。
|
||||
|
||||
## 日志去哪了 {#where-it-goes}
|
||||
|
||||
对 **stdio** 服务器来说,这个问题比平时更要紧。宿主把你的服务器作为子进程启动,并从它的 **stdout** 读取 MCP 消息。标准错误才是你的。
|
||||
|
||||
标准库默认就做对了:日志输出默认写到 `sys.stderr`。你的 `logger.info(...)` 会落在终端里(或者宿主收集子进程 stderr 的任何地方),协议流保持干净。
|
||||
|
||||
!!! tip
|
||||
不要在 stdio 服务器里 `print()`。`print` 写的是 **stdout**,而 stdout 属于协议。在服务期间,SDK 会把真正被**刷新**(flush)出去的 stdout 转到 stderr,所以它不会破坏线路;但在块缓冲的进程里,`print()` 的内容通常会一直留在 `sys.stdout` 的缓冲区里没有刷新,直到解释器在退出时把它排空——直接排到协议流上。即使被转走了,这一行也是原样混在日志输出当中,没有级别、没有 logger 名称,也没办法过滤。
|
||||
|
||||
`logger.debug("got here")` 同样只是一行的功夫,而且会去到正确的地方。
|
||||
|
||||
## 日志级别 {#the-level}
|
||||
|
||||
不需要自己调用 `logging.basicConfig()`。构造 `MCPServer` 时已经调用过了:配了一个指向标准错误的 handler,级别就是你通过 `log_level=` 传入的值。所以只要 `MCPServer("Bookshop", log_level="DEBUG")`,就能看到你的 `logger.debug(...)` 输出。
|
||||
|
||||
默认值是 `"INFO"`。
|
||||
|
||||
`logging.basicConfig()` 永远不会替换已经存在的 handler。如果你在创建服务器之前自己配置了日志,以你的配置为准。
|
||||
|
||||
## 试一试 {#try-it}
|
||||
|
||||
用 MCP Inspector 运行服务器:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
在 **Tools** 标签页调用 `search_books`。Inspector 显示的结果只有返回值。这一行
|
||||
|
||||
```text
|
||||
Searching for 'dune'
|
||||
```
|
||||
|
||||
去了标准错误:终端,而不是线路。
|
||||
|
||||
!!! info
|
||||
如果你真正想要的是**追踪**(每个请求、耗时多久、是否失败),那你要的不是日志行,而是 span。你的服务器已经在产出它们了:SDK 默认就用 OpenTelemetry 追踪每一条消息。见 **[OpenTelemetry](../run/opentelemetry.md)**。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* MCP 协议的日志能力已被 2026-07-28 规范弃用,且没有替代。不要基于它构建。
|
||||
* 模块级写 `logger = logging.getLogger(__name__)`,工具里写 `logger.info(...)`。整个模式就这些。
|
||||
* 日志输出永远到不了模型那里。只有你 `return` 的值才会。
|
||||
* 标准错误是你的;stdout 属于协议。服务期间 SDK 会把已刷新的零散 stdout 转到 stderr,但没刷新的 `print()` 仍可能在退出时排到线路上,而且被转走的行没有任何标记;用 `logging`,它的 handler 每条记录都会刷新。
|
||||
* `MCPServer(..., log_level="DEBUG")` 设置级别;你先做好的日志配置不会被改动。
|
||||
|
||||
告诉已连接的客户端服务器上有东西变了(工具列表、某个资源),见 **[订阅](subscriptions.md)**。
|
||||
183
i18n/zh/pages/handlers/multi-round-trip.md
Normal file
183
i18n/zh/pages/handlers/multi-round-trip.md
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
---
|
||||
translation:
|
||||
sections: [74011e683045eea9, 9b64cc175c18b6a9, 4b41be4824030397, e3b1502da786ec33, 71e41161f143c6a9, 9ec2c1eeb8c36378, 8dd027377d46448b, f81491125dcbfe8b]
|
||||
tool: 1
|
||||
---
|
||||
# 多轮往返(multi-round-trip)请求 {#multi-round-trip-requests}
|
||||
|
||||
有时一个工具没法在一次往返内完成。它需要只有用户才有的东西:一个选择、一次确认、一份凭据。
|
||||
|
||||
在 2026-07-28 之前,服务器靠**回调**拿到它:在处理原请求的中途,自己向客户端发起一个请求——一次征询(elicitation)、一次采样(sampling)调用。2026-07-28 规范移除了这条反向通道(back-channel)。
|
||||
|
||||
取而代之的是,服务器**返回**。
|
||||
|
||||
## 返回,而不是回调 {#return-dont-call-back}
|
||||
|
||||
服务器用 **`InputRequiredResult`** 而不是 `CallToolResult` 来响应 `tools/call`。起作用的是其中两个字段:
|
||||
|
||||
* **`input_requests`**:服务器还需要什么,形式是一个 dict,键是服务器自己选的名字。每个值是一个 `ElicitRequest`、`CreateMessageRequest` 或 `ListRootsRequest`。
|
||||
* **`request_state`**:一个不透明的令牌。客户端在重试时原样回传。只有你的服务器会读它。
|
||||
|
||||
客户端满足每个请求,然后**再次调用同一个工具**,把答案放在 `input_responses` 里,令牌放在 `request_state` 里。服务器这时拿到了缺的东西,返回一个普通的 `CallToolResult`。
|
||||
|
||||
整个协议就是这样。每一轮都是客户端发给服务器的普通请求,没有任何东西反方向流动。
|
||||
|
||||
## 服务器端 {#the-server-side}
|
||||
|
||||
在 `@mcp.tool()` 上很少需要手动构造它:声明一个向用户提问(`Elicit`)、对客户端的 LLM 采样(`Sample`)或列出客户端根目录(roots,`ListRoots`)的依赖,SDK 就会替你返回 `InputRequiredResult`;这种形式见 **[依赖](dependencies.md)** 页面。两种形式不能混用:一次调用只有一条 `input_responses`/`request_state` 通道,所以使用 `Resolve(...)` 参数的工具不能再从函数体返回 `InputRequiredResult`。声明了 `InputRequiredResult` 返回类型的会在注册时被拒绝(`InvalidSignature`),没声明的则在运行时让调用失败。手动形式是**低层** `Server`,它的 `on_call_tool` 处理函数可以返回两种结果类型中的任意一种:
|
||||
|
||||
```python title="server.py" hl_lines="43-46"
|
||||
--8<-- "docs_src/mrtr/tutorial001.py"
|
||||
```
|
||||
|
||||
* `on_call_tool` 的类型标注是 `-> CallToolResult | InputRequiredResult`。返回后者就是服务器端的全部 API。
|
||||
* 第一次调用时 `params.input_responses` 是 `None`,于是守卫条件成立,处理函数提问而不是回答。
|
||||
* 重试时,客户端发来的 `ElicitResult` 就在服务器在 `input_requests` 里用过的**同一个键**(`"region"`)下。
|
||||
|
||||
那个文件里的其他内容(显式的 `input_schema`、手工构造的 `CallToolResult`)都是普通的低层 `Server`,详见 **[低层 Server](../advanced/low-level-server.md)**。本页只是多加了第二种返回类型。
|
||||
|
||||
## 不止于工具 {#beyond-tools}
|
||||
|
||||
`tools/call` 并不特殊:在 2026-07-28 下,服务器可以用同样的方式响应 `prompts/get` 和 `resources/read`。在 `MCPServer` 上,`@mcp.prompt()` 函数——或 `@mcp.resource()` **模板**函数——自己返回 `InputRequiredResult`,并从上下文里读取重试带来的答案:
|
||||
|
||||
```python title="server.py" hl_lines="20 22 24"
|
||||
--8<-- "docs_src/mrtr/tutorial004.py"
|
||||
```
|
||||
|
||||
* 第一轮返回 `InputRequiredResult`。重试时,`ctx.input_responses` 在同样的键下保存着答案,函数返回它的普通结果——这里是提示词消息,对模板资源来说是资源内容。
|
||||
* 你设置的 `request_state` 在上线路之前会被密封,回传时会被校验,和服务器上的其他状态一样;下面的 **[保护 `requestState`](#protecting-requeststate)** 说明密封带来了什么、什么时候需要配置密钥。
|
||||
* 当依赖形式不合适时,`@mcp.tool()` 函数也可以用同样的方式直接返回这个结果。
|
||||
* 静态的 `@mcp.resource()` 函数不参与:它们不接收 `Context`,所以永远读不到重试。只有模板资源能提问。
|
||||
* 下文关于协议时代的规则原样适用:在 2026 之前的会话上返回 `InputRequiredResult`,就是警告里描述的那个 `-32603`。
|
||||
|
||||
## 客户端 {#the-client-side}
|
||||
|
||||
`Client` 替你跑这个循环。
|
||||
|
||||
注册服务器可能用到的回调(`elicitation_callback`、`sampling_callback`、`list_roots_callback`),然后调用工具。`InputRequiredResult` 到达时,`Client` 把 `input_requests` 里的每一项分派给对应的回调,带着答案和回传的 `request_state` 重试,一直持续到拿回 `CallToolResult`:
|
||||
|
||||
```python title="client.py" hl_lines="11 12"
|
||||
--8<-- "docs_src/mrtr/tutorial003.py"
|
||||
```
|
||||
|
||||
* 那个 `elicitation_callback` 正是 2026 之前的服务器通过反向通道发出的 `elicitation/create` 会命中的那个。`sampling_callback` 之于 `sampling/createMessage`、`list_roots_callback` 之于 `roots/list` 也一样:在 2026-07-28 下,独立的服务器->客户端 RPC 没有了,但完全相同的 `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` 载荷搭在 `input_requests` 里,分派给同样的三个回调。一套回调服务两个时代。
|
||||
* `call_tool` 返回普通的 `CallToolResult`。中间的轮次对调用方不可见。
|
||||
* `get_prompt` 和 `read_resource` 驱动同一个循环。
|
||||
|
||||
!!! check
|
||||
去掉回调,循环在第一轮就会失败:SDK 的占位回调会用错误回答每一次征询,`call_tool` 抛出 `MCPError`,消息是“Elicitation not supported”。
|
||||
|
||||
循环是有界的。`Client(..., input_required_max_rounds=10)` 是默认上限;服务器超过上限还在返回 `InputRequiredResult`,`call_tool` 就会抛出异常。如果某一轮只带 `request_state` 而没有 `input_requests`,`Client` 会在重试前短暂休眠(50 ms 起翻倍,上限 250 ms),这样一个只是在说“还没好”的服务器不会被忙轮询。
|
||||
|
||||
### 自己驱动循环 {#driving-the-loop-yourself}
|
||||
|
||||
自动循环对单进程客户端已经够用。遇到以下情况,改为自己掌控循环:
|
||||
|
||||
* 客户端是**分布式**的:把问题呈现给用户的进程不是调用 `call_tool` 的进程,所以重试由另一个 worker 发出。`request_state` 是跨越这条边界、经由你自己的存储携带的可持久化令牌;`input_responses` 是另一侧连同它一起发回的东西。
|
||||
* 想**检查**每一轮:记录或审计每一个 `input_requests` 项,拒绝某些类型的请求,或在两轮之间应用自己的退避策略。
|
||||
* 想要**挂钟时间**的上限而不是轮数上限:把自己的循环包在 `anyio.fail_after(...)` 里,而不是依赖 `input_required_max_rounds`。
|
||||
|
||||
下探到底层 session,在那里 `allow_input_required=True` 直接把联合类型交给你:
|
||||
|
||||
```python title="client.py" hl_lines="12 13 19"
|
||||
--8<-- "docs_src/mrtr/tutorial002.py"
|
||||
```
|
||||
|
||||
* `client.session.call_tool(..., allow_input_required=True)` 把返回类型放宽为 `CallToolResult | InputRequiredResult`。`isinstance` 负责把它重新收窄。
|
||||
* `request_state` 现在在你手上。两轮之间把它记下来,对话就能从一个全新的进程恢复。
|
||||
* 对 `input_requests` 里的每一项,在 `input_responses` 的**同一个键**下放一个 `InputResponse`。`fulfil` 是放你的 UI 的地方;这个例子把答案写死了。
|
||||
* 每一轮都是同一个工具名、同样的 `arguments`。重试是把原调用再执行一遍,不是一个新方法。
|
||||
|
||||
## 保护 `requestState` {#protecting-requeststate}
|
||||
|
||||
上面一直把 `request_state` 当作回传,在线路上它也确实只是这样。但客户端在两轮之间持有它(跨进程记下来正是上一节认可的做法),所以回来的东西是**客户端提供的输入**:它可能被改动、过期,或者干脆是从另一次调用里搬来的。规范要求,只要这个状态能影响授权、资源访问或业务逻辑,服务器就必须对它做完整性保护,并在校验失败时拒绝这一轮。
|
||||
|
||||
`MCPServer` 默认就保护它。每个服务器都会用进程启动时生成的密钥密封发出的 `requestState`,并校验每一次回传——解析器状态和手工构造的状态都一样。你什么都不用配置,写的是明文,读的也是明文;线路上只会出现一个不透明的加密令牌。
|
||||
|
||||
默认密钥与进程同生共死,这是部署到单进程之外前必须知道的一件事:
|
||||
|
||||
```python
|
||||
from mcp.server.mcpserver import MCPServer, RequestStateSecurity
|
||||
|
||||
# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each).
|
||||
mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key]))
|
||||
```
|
||||
|
||||
* **默认(不配置)**适合单进程:stdio,或恰好一个 HTTP worker。落到另一个 worker、负载均衡器后面的另一个实例、或重启后的同一服务器上的重试,是用那个进程没有的密钥密封的——客户端会收到下面那条固定的拒绝,必须从头开始这个流程。
|
||||
* 只要重试可能到达**另一个实例**(多 worker 的 `uvicorn`、负载均衡的 HTTP)或必须熬过重启,就需要 **`keys=[...]`**:每个实例都能校验任何同伴签发的东西。同样的机制,只是用你的密钥替代生成的密钥。
|
||||
* 要用自己的加密方案,比如 KMS 或已有的令牌服务,传 `RequestStateSecurity(codec=...)` 而不是 `keys`;下面的 **[自带加密](#bring-your-own-crypto)** 说明了契约。
|
||||
|
||||
### 密封里带了什么 {#what-the-seal-carries}
|
||||
|
||||
无论默认还是配置过,线路上的 `requestState` 都是一个加密且经过认证的令牌。你的代码永远看不到它:处理函数和解析器写明文、读明文(`ctx.request_state`);SDK 在发出时密封,在收到时校验。除了完整性,每个令牌还绑定到:
|
||||
|
||||
* **一个时间窗口。** 每一轮都用新的过期时间重新密封,所以 `RequestStateSecurity(ttl=...)`(默认 600 秒)限制的是每轮的思考时间,而不是整个流程。
|
||||
* **已认证的主体。** 当请求携带一个经 SDK 校验的 OAuth 访问令牌时,状态绑定到该令牌的客户端、颁发者和 subject:为一个用户签发的状态在另一个用户下会失败,即使两个用户共用一个 OAuth 客户端。不提供 subject 的校验器会让绑定退化为仅客户端身份,而在基于 URL 的客户端 ID 下,这个身份由该客户端软件的所有用户共享。当认证在 SDK 之外终结(前置代理),或传输未经认证时,没有主体可绑定,这项检查不起作用,除非 `RequestStateSecurity(bind_principal=...)` 从你自己的身份信号提供一个。无论你的令牌校验器提供哪些组成部分,都必须一致地提供:一个在某些请求上包含 subject、在另一些请求上省略它的校验器会在流程中途改变主体,进行中的轮次会被拒绝。
|
||||
* **发起的请求。** 方法、工具或提示词名称(或资源 URI),以及参数的摘要。针对不同工具、不同参数或不同方法重放的令牌会失败。
|
||||
* **所问的确切问题。** 每个解析器答案都钉在客户端看到的那个渲染后的问题上,无论是它第一次到达的那一轮,还是之后复用已记录答案的时候。换了措辞的消息或改过的 schema 重新部署后,服务器会重新提问,而不是吞下一个过期的答案。同样的钉住也有反面:要从工具的参数派生消息,而不是从每次调用的数据派生。用时间戳或实时汇率构造的消息每一轮渲染都不一样,于是每个已记录的答案看起来都过期了,服务器一直重新提问,直到客户端的轮数上限结束这次调用。
|
||||
|
||||
这些全是 SDK 的工作,不是你的;如果你自带 codec,也不是 codec 的。
|
||||
|
||||
### 轮换密钥 {#rotating-keys}
|
||||
|
||||
`keys[0]` 密封新状态;列表里的每个密钥都参与校验。零停机轮换分三个阶段,每个阶段完全铺开后再进入下一个:
|
||||
|
||||
```python
|
||||
RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints
|
||||
RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying
|
||||
RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD
|
||||
```
|
||||
|
||||
永远不要先提升签发密钥:用某个实例还不能校验的密钥签发,会在铺开途中丢掉进行中的轮次。
|
||||
|
||||
密钥的作用域是单个服务。密封的信封还把服务器的名字作为 audience 声明带上,所以另一个恰好共用密钥的服务签发的令牌照样会被拒绝。这个声明的区分度取决于名字,所以被赋予显式策略的服务器必须有一个真实的名字,或者设置 `RequestStateSecurity(audience=...)`——没有名字的会在构造时抛出异常。`audience=` 也服务于有意为之的多服务拓扑,即一个服务必须接受另一个服务签发的状态。(不配置的默认情形不受此限:它的密钥从不离开进程,audience 声明没有什么可补充的。)
|
||||
|
||||
### 自带加密 {#bring-your-own-crypto}
|
||||
|
||||
`RequestStateSecurity(codec=...)` 接受任何带有 `seal(bytes) -> str` 和 `unseal(str) -> bytes`、并对任何不是自己签发的令牌抛出 `InvalidRequestState` 的对象。典型形态是基于 KMS 的信封加密:启动时解包一次数据密钥,每个令牌的加解密留在本地:
|
||||
|
||||
```python title="server.py" hl_lines="12 26-27 34-35 38"
|
||||
--8<-- "docs_src/mrtr/tutorial005.py"
|
||||
```
|
||||
|
||||
TTL、主体绑定和请求绑定**不是** codec 的工作:对每个 codec,SDK 都在 `seal` 之前把它们印进载荷,在 `unseal` 之后重新校验。codec 唯一的义务是完整性(被篡改就抛出异常),以及最好有机密性。
|
||||
|
||||
### 校验失败时 {#when-verification-fails}
|
||||
|
||||
每一个入站失败,无论是被篡改、过期、针对不同请求或主体重放,还是用本服务器不认识的密钥密封的,得到的都是同一个回答:
|
||||
|
||||
```json
|
||||
{"code": -32602, "message": "Invalid or expired requestState"}
|
||||
```
|
||||
|
||||
所有原因都是同一条固定消息,这样线路上永远不会泄露哪项检查失败了;真正的原因写进服务器日志。`tools/call`、`prompts/get` 和 `resources/read` 上每一个入站的 `requestState` 都会被检查,包括发给一个从不签发状态的处理函数的。实践中最常见的拒绝不是攻击者——而是默认的进程本地密钥遇上了来自重启之前或另一个实例的重试;客户端重新开始流程,需要在意时 `keys=[...]` 就是解法。
|
||||
|
||||
### 手工构造的状态 {#hand-built-state}
|
||||
|
||||
你自己设置的 `request_state`(从工具、提示词或资源模板函数返回 `InputRequiredResult`)由与解析器状态相同的机制密封和校验,代码一行不用改:写明文、读明文,上面的每一项绑定都适用。
|
||||
|
||||
即使配置过,SDK 唯一无法替你钉住的是问题的身份:它不知道你状态里的某个答案属于**你的**哪一个问题。如果按问题为键存答案,就在状态里放进你自己的问题标识符,并在重试时检查它。
|
||||
|
||||
低层 `Server` 是什么都不自带的那一层:和 `MCPServer` 不同,在你自己加上这道边界之前什么都不会被密封,在那之前你的 `request_state` 按原样跨越线路。一行代码的启用方式见 **[低层 Server](../advanced/low-level-server.md#the-other-handlers)**。
|
||||
|
||||
## 一个 2026-07-28 的结果 {#a-2026-07-28-result}
|
||||
|
||||
`InputRequiredResult` 只存在于协议版本 **2026-07-28**。内存中的 `Client(server)` 替你协商它;走线路时,`mode="auto"` 会发现它。连接之后,`client.protocol_version` 告诉你拿到的是什么。
|
||||
|
||||
!!! warning
|
||||
2026 之前的会话没有地方放 `InputRequiredResult`。在 `mode="legacy"` 连接上从处理函数返回一个,运行器无法把它序列化到协商好的版本;客户端收到的是 `-32603`“Handler returned an invalid result”错误。同时服务两个时代的服务器在用它之前必须检查 `ctx.protocol_version`。
|
||||
|
||||
!!! info
|
||||
**URL 模式的征询**在 2026 连接上走的正是这套机制。`input_requests` 里的那一项是一个 params 为 `ElicitRequestURLParams` 的 `ElicitRequest`;用户完成带外流程,你的客户端重试调用。同一个循环,没有新 API。高层服务器那一半见 **[征询](elicitation.md)**。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 在 2026-07-28 下,调用中途需要输入的服务器**返回**一个 `InputRequiredResult`。它从不向客户端发起请求。
|
||||
* `input_requests` 是它需要的东西。`request_state` 是只有服务器会读的不透明恢复令牌。
|
||||
* `Client` 替你跑重试循环:注册 `elicitation_callback` / `sampling_callback` / `list_roots_callback`,`call_tool` 就返回普通的 `CallToolResult`。`input_required_max_rounds`(默认 10)给它设了上限。
|
||||
* 要检查或持久化轮次,用 `client.session.call_tool(..., allow_input_required=True)`,自己掌控 `while isinstance(result, InputRequiredResult)` 循环。
|
||||
* 在 `@mcp.tool()` 上,一个向用户提问的依赖会替你产生这个结果(**[依赖](dependencies.md)**);**低层** `Server` 是手动形式。
|
||||
* 提示词和资源也参与:`@mcp.prompt()` 或模板 `@mcp.resource()` 函数自己返回 `InputRequiredResult`,重试时读取 `ctx.input_responses`。
|
||||
* `requestState` 回来时是客户端提供的输入,所以 `MCPServer` 默认用进程本地密钥密封它——解析器状态和手工构造的状态都一样;多实例部署传入 `RequestStateSecurity(keys=[...])`(或自定义 codec),让每个实例都能校验同伴签发的东西。密封把每个令牌绑定到一个时间窗口、发起的请求,以及已认证的主体——当请求携带经 SDK 校验的认证信息,或 `bind_principal=` 提供了你自己的身份信号时(**[保护 `requestState`](#protecting-requeststate)**)。
|
||||
|
||||
这就是取代服务器发起的采样以及其余推送式反向通道的机制;见 **[已弃用的功能](../deprecated.md)**。
|
||||
112
i18n/zh/pages/handlers/progress.md
Normal file
112
i18n/zh/pages/handlers/progress.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
---
|
||||
translation:
|
||||
sections: [5315262fe26b33e1, 9d8e98840f1b78f0, 0284b215e85366c4, 8534d8dbb4053a70, 2966fac6fe697007]
|
||||
tool: 1
|
||||
---
|
||||
# 进度 {#progress}
|
||||
|
||||
一个要跑三十秒的工具,如果这三十秒里一声不吭,看起来就像坏了。
|
||||
|
||||
**进度通知**解决的就是这个问题。工具报告自己做到哪了;客户端决定拿它画什么:进度条、旋转指示器,还是一行日志。
|
||||
|
||||
## 从工具里报告 {#report-it-from-the-tool}
|
||||
|
||||
接收一个 **`Context`** 参数,然后调用 `report_progress`:
|
||||
|
||||
```python title="server.py" hl_lines="8 11"
|
||||
--8<-- "docs_src/progress/tutorial001.py"
|
||||
```
|
||||
|
||||
三个参数,含义由你决定:
|
||||
|
||||
* `progress`:做到哪了。规范要求它每次报告都**递增**;不要重复同一个值,也不要倒退。
|
||||
* `total`:总共有多少,如果你知道的话。可选。
|
||||
* `message`:描述**这一步**的一行人类可读文字。可选。
|
||||
|
||||
`ctx` 是因为类型注解被注入的,模型永远看不到它:`import_catalog` 的输入模式只有一个属性 `urls`。**[Context](context.md)** 页面专门讲这个对象;进度只是它提供的功能之一。
|
||||
|
||||
## 从客户端监听 {#listen-for-it-from-the-client}
|
||||
|
||||
客户端**按调用**选择接收,方法是给 `call_tool` 传 `progress_callback=`:
|
||||
|
||||
```python title="client.py" hl_lines="7 16"
|
||||
import anyio
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
async def show(progress: float, total: float | None, message: str | None) -> None:
|
||||
print(f"{message} ({progress}/{total})")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"import_catalog",
|
||||
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
|
||||
progress_callback=show,
|
||||
)
|
||||
print(result.structured_content)
|
||||
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
回调是一个 `async` 函数,接收的正是服务器报告的内容:`progress`、`total`、`message`。
|
||||
|
||||
!!! info
|
||||
`Client(mcp)` 直接在内存中连接到服务器对象,和 **[测试](../get-started/testing.md)** 页面所用的是同一个客户端。无论 `Client` 用哪种传输方式,`progress_callback` 都是同一个参数;接下来看到的**时序**则是内存连接特有的。它以内联方式运行你的回调,所以每条报告都在 `call_tool` 返回之前送达。换成真实的传输方式,通知会和结果竞速,`call_tool` 已经返回之后,一个慢的回调可能还在运行。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
把 `client.py` 放在 `server.py` 旁边,然后运行:
|
||||
|
||||
```console
|
||||
python client.py
|
||||
```
|
||||
|
||||
```text
|
||||
Imported https://example.com/a.json (1/2)
|
||||
Imported https://example.com/b.json (2/2)
|
||||
{'result': 'Imported 2 records.'}
|
||||
```
|
||||
|
||||
服务器上的每一次 `await ctx.report_progress(...)` 都变成了客户端上对 `show` 的一次调用,顺序不变,而且两行都在 `call_tool` 返回**之前**打印了出来。进度不会打包进结果里;它在工具还在干活的时候就流式送出。
|
||||
|
||||
!!! warning
|
||||
`progress_callback` 属于**调用**,而不是 `Client`。没有对应的构造函数参数,因为不同的调用想要不同的回调:这一次驱动下载进度条,下一次是一行日志。
|
||||
|
||||
!!! check
|
||||
现在删掉 `progress_callback=show`,再运行一次:
|
||||
|
||||
```text
|
||||
{'result': 'Imported 2 records.'}
|
||||
```
|
||||
|
||||
没有错误,没有警告,结果相同。`report_progress` **在调用方没有请求进度时是空操作**,所以可以无条件地报告,永远不用操心有没有人在听。
|
||||
|
||||
## 不知道总量时 {#when-you-dont-know-the-total}
|
||||
|
||||
`total` 用在知道分母的时候。很多时候并不知道:你在消费一个 feed、遍历一个游标、下载一个没有长度头的东西。
|
||||
|
||||
那就省略它:
|
||||
|
||||
```python title="server.py" hl_lines="20"
|
||||
--8<-- "docs_src/progress/tutorial002.py"
|
||||
```
|
||||
|
||||
回调收到的是 `total=None`。客户端仍然可以显示**有动静**(“目前已导入 3 条……”),但显示不了百分比。不要为了进度条好看而编造一个总量。
|
||||
|
||||
!!! tip
|
||||
`progress` 不一定非得数某样特定的东西。字节、行、页:选用户认得出的单位,并且只承诺你能兑现的 `total`。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 在任何接收 `Context` 的工具里调用 `await ctx.report_progress(progress, total=None, message=None)`。
|
||||
* 客户端给 `call_tool` 传 `progress_callback=`:按调用传,永远不在 `Client` 上设。
|
||||
* 回调的形式是 `async (progress, total, message) -> None`,在工具还在运行时就会触发。
|
||||
* 调用上没有回调,`report_progress` 就什么都不做。无条件地报告即可。
|
||||
* 不知道 `total` 就省略;回调拿到的是 `None`。
|
||||
|
||||
进度是运行中的工具展示给**用户**看的。它为**你**——运维这台服务器的人——记录的那些日志行走的是另一条通道:**[日志](logging.md)**。
|
||||
51
i18n/zh/pages/handlers/sampling-and-roots.md
Normal file
51
i18n/zh/pages/handlers/sampling-and-roots.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
translation:
|
||||
sections: [5c82b20cbd65ded0, 9dc22632be79a533, 1fb8f452e990c456, 42666ab914ff0cb1, c4e0cb3667fd5ff9]
|
||||
tool: 1
|
||||
---
|
||||
# 采样与根目录 {#sampling-and-roots}
|
||||
|
||||
处理函数还可以向已连接的客户端索取两样东西:一是用客户端自己的模型生成一次补全,即**采样**(sampling);二是客户端的工作区文件夹,即**根目录**(roots)。
|
||||
|
||||
两者在 SDK 支持的每个协议版本上都仍然可用。但在围绕它们做设计之前,先读下面的警告:
|
||||
|
||||
!!! warning "已被 2026-07-28 规范弃用"
|
||||
采样和根目录自 `2026-07-28` 起已弃用([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577))。它们仍然完全可用,并且会在规范中至少保留十二个月才有可能被移除,但新的实现不应再建立在它们之上。建议的迁移方式:直接对接 LLM 提供商的 API,而不是使用采样;通过工具参数、资源 URI 或服务器配置传递目录,而不是使用根目录。SDK 范围内的完整清单见 **[已弃用的功能](../deprecated.md)**。
|
||||
|
||||
## 采样:借用客户端的模型 {#sampling-borrow-the-clients-model}
|
||||
|
||||
解析器返回 `Sample(...)`,工具就会收到补全结果,走的是和 **[依赖](dependencies.md)** 中运行 `Elicit` 相同的依赖机制:
|
||||
|
||||
```python title="server.py" hl_lines="10-15 19"
|
||||
--8<-- "docs_src/sampling_and_roots/tutorial001.py"
|
||||
```
|
||||
|
||||
* `Sample(messages, max_tokens=...)` 与 `sampling/createMessage` 的参数一一对应。注入的值是客户端的 `CreateMessageResult`;如果传入 `tools` 或 `tool_choice`,注入的就是 `CreateMessageResultWithTools`。
|
||||
* 客户端必须声明了 `sampling` 能力(如果传入 `tools` 或 `tool_choice`,则需要 `sampling.tools`)。如果没有声明,调用会以 `-32021` 协议错误失败,而不会发出一个客户端无法处理的请求。没有反向通道(back-channel)的 2026 年之前的会话,会照常以无反向通道的错误失败,因为根本没有可以发送的通道。
|
||||
* 在 `2026-07-28` 上,请求在多轮往返(multi-round-trip)流程中送达(见 **[多轮往返请求](multi-round-trip.md)**);在 `2025-11-25` 上,它是发给客户端的一个独立请求。两种情况下代码都一样,但要注意多轮往返的规则:请求在各轮重试中必须渲染得完全一致,所以只能用工具的参数和其他稳定数据来构造它。
|
||||
* 不要动 `include_context`:`"none"` 以外的值本身也已弃用(SEP-2596),而且需要一个几乎没有客户端会声明的能力。
|
||||
|
||||
## 根目录:这个该放哪儿? {#roots-where-should-this-go}
|
||||
|
||||
根目录是客户端声明服务器可以操作的文件夹。它们只是参考信息,不是访问控制机制。解析器返回 `ListRoots()`:
|
||||
|
||||
```python title="server.py" hl_lines="10-11 15"
|
||||
--8<-- "docs_src/sampling_and_roots/tutorial002.py"
|
||||
```
|
||||
|
||||
* 注入的 `ListRootsResult` 带有一个 `Root` 列表:每项是一个 `file://` URI 和一个可选的显示名称。
|
||||
* 门槛和采样一样:没有声明 `roots` 能力时,调用会以 `-32021` 失败,而不会发出请求。
|
||||
|
||||
在线路的另一端,客户端用它已有的回调来响应这两种请求:`sampling_callback` 和 `list_roots_callback`,详见 **[客户端回调](../client/callbacks.md)**。
|
||||
|
||||
## 在 2025 年代的连接上 {#on-2025-era-connections}
|
||||
|
||||
`ctx.session.create_message(...)` 和 `ctx.session.list_roots()` 仍然存在,供直接驱动会话的代码使用。它们只在存在反向通道的地方有效(2025 年代、非无状态的连接),并且调用它们会触发弃用警告。上面的解析器标记才是受支持的形式:它们根据协商出的版本选择投递方式,也不会发出警告。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 从解析器返回 `Sample(...)` 或 `ListRoots()`;工具会像接收其他任何依赖项一样收到 `CreateMessageResult` 或 `ListRootsResult`。
|
||||
* 客户端必须声明对应的能力,否则调用会以 `-32021` 失败,而不会发出请求。
|
||||
* 两个功能在 `2026-07-28` 上都已弃用:目前完全可用,但不适合新的设计。优先使用提供商 API 而非采样,优先使用显式参数而非根目录。
|
||||
|
||||
报告一个耗时工具的进度:**[进度](progress.md)**。
|
||||
152
i18n/zh/pages/handlers/subscriptions.md
Normal file
152
i18n/zh/pages/handlers/subscriptions.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
---
|
||||
translation:
|
||||
sections: [60a9de8a0bdaa531, 317bbe7e4355cdcc, a61d660c8029e04a, 8f7e82fcb88df8a9, b165db51249ff8ed, 266f56fb798068a4, 7c0e57030b622139, df18d7c2417a9883]
|
||||
tool: 1
|
||||
---
|
||||
# 订阅 {#subscriptions}
|
||||
|
||||
服务器的目录不是固定的。工具会在运行时出现,资源 URI 背后的内容也会变化。
|
||||
|
||||
**订阅(subscriptions)**就是客户端得知这些变化的方式。客户端发送一个 `subscriptions/listen` 请求,而这个请求的响应**就是**流本身:它保持打开,承载客户端要求的变更通知。
|
||||
|
||||
## 在工具里发布变更 {#publish-it-from-the-tool}
|
||||
|
||||
你这一边只需要一行:发布变更。
|
||||
|
||||
```python title="server.py" hl_lines="20 32"
|
||||
--8<-- "docs_src/subscriptions/tutorial001.py"
|
||||
```
|
||||
|
||||
* `await ctx.notify_resource_updated("board://sprint")` 会送达每一个订阅了该 URI 的打开中的流。其他人收不到。
|
||||
* `await ctx.notify_tools_changed()` 会送达每一个要求接收工具列表变更的流。收到它的客户端会再次调用 `tools/list`,这时就能看到 `sprint_report`。
|
||||
* 同类方法还有 `notify_prompts_changed()` 和 `notify_resources_changed()`。
|
||||
* 没有订阅者,就没有开销。向空闲的服务器发布是空操作,所以永远不需要检查有没有人在听。只管声明什么变了。
|
||||
|
||||
`MCPServer` 替你处理 `subscriptions/listen`。线路上的义务(第一帧是确认、按流过滤、每一帧都带订阅 id)是 SDK 的事。
|
||||
|
||||
!!! check
|
||||
在线路上,一个过滤器里指定了 `board://sprint` 的流,在 `complete_task` 运行之后是这样的:
|
||||
|
||||
```json
|
||||
{"method": "notifications/subscriptions/acknowledged",
|
||||
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
|
||||
|
||||
{"method": "notifications/resources/updated",
|
||||
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
|
||||
```
|
||||
|
||||
注意这条更新**没有**携带什么:看板本身。每一帧都在 `_meta` 下携带 listen 请求的 JSON-RPC id,这个 id 就是订阅 id。它由客户端生成:Python 的 `Client` 用 `"listen-1"` 这样的字符串;其他客户端可能用整数。
|
||||
|
||||
## 只给要求的内容 {#only-what-was-asked-for}
|
||||
|
||||
过滤器是一份契约。一个请求了工具列表变更和一个资源 URI 的流,只会收到这两类,别的什么都没有。发布一条提示词变更,那个流保持沉默。
|
||||
|
||||
`MCPServer` 把资源 URI 当作精确字符串来匹配,所以指定了 `board://sprint` 的流听不到任何关于 `board://sprint/tasks/1` 的消息。规范允许服务器报告已订阅 URI 的子资源上的变更;`MCPServer` 从不这么做,但客户端被设计为要能应对这种情况。
|
||||
|
||||
流**不是**的两样东西:
|
||||
|
||||
* **它不是重放日志。** 断掉的流就没了,没人连接时发布的事件不会排队。客户端要重新 listen 并重新获取。
|
||||
* **它不是 2025 的路径。** 调用了 `resources/subscribe` 的客户端由 `ctx.session.send_resource_updated(uri)` 服务。`notify_*` 方法只送达 `subscriptions/listen` 流。
|
||||
|
||||
## 决定谁可以观察 {#deciding-who-may-watch}
|
||||
|
||||
默认情况下,请求的每一种类别和 URI 都会被接受:任何调用方都可以观察你发布的任何 URI。没有任何东西会去查你的读取处理函数,因为没人在读取——一个会被你的 `files://{name}` 处理函数拒之门外的调用方,仍然可以在 `files://payroll.csv` 上打开一个流,得知它变了,以及什么时候变的。它永远拿不到内容,也无法探测哪些东西存在,因为未知的 URI 同样会被接受,只是永远不会触发。范围窄,但确实存在,所以在多租户服务器发布按用户区分的 URI 之前,先加上门控。
|
||||
|
||||
门控是一个中间件。它在 SDK 确认之前看到 `subscriptions/listen` 请求,当调用方要求了任何它无权读取的东西时就拒绝:
|
||||
|
||||
```python title="server.py" hl_lines="19-26 29"
|
||||
--8<-- "docs_src/subscriptions/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ctx.params` 是原始请求,所以中间件自己把它校验成 `SubscriptionsListenRequestParams`,再读取客户端要求的过滤器。
|
||||
* 拒绝就是在 `call_next(ctx)` 之前抛出 `MCPError`:客户端收到这个错误而没有流,连接照常继续。让消息保持统一、不点名任何 URI,这样拒绝永远不会证实哪些 URI 是受保护的。
|
||||
* 一个 `can_access(user, uri)` 同时回答两个问题。资源处理函数在 `resources/read` 时问它;中间件在 `subscriptions/listen` 时问它。把这张表换成数据库或你的 RBAC 系统,两边依然保持一致。
|
||||
* 这个决定在流的整个生命周期内有效。没有逐事件的重新检查,所以如果调用方的访问权限可能在流途中失效(令牌过期),就在失效时结束该调用方的连接。
|
||||
|
||||
完整的中间件契约,包括它还包裹了什么、为什么被标记为暂定,见 **[中间件](../advanced/middleware.md)**。
|
||||
|
||||
## 客户端这一端 {#the-client-end}
|
||||
|
||||
下面是流另一侧的一个客户端,跟踪着看板:
|
||||
|
||||
```python title="client.py" hl_lines="15"
|
||||
--8<-- "docs_src/subscriptions/tutorial003.py"
|
||||
```
|
||||
|
||||
进入 `client.listen(...)` 会发送请求并等待你的确认,所以代码块开始时流已经是活的,每个带类型的事件都是重新获取的信号,从来不是载荷。这就是一屏之内的整份契约。关于客户端这一端的其他所有内容都在它自己的页面上:在主流程旁边观察、流的结束、以及重新 listen。见“客户端”下的 **[订阅](../client/subscriptions.md)**。
|
||||
|
||||
## 扩展到多个进程 {#scaling-past-one-process}
|
||||
|
||||
发布通过一个 `SubscriptionBus` 从你的处理函数传到打开的流。默认是内存内的:一个进程,里面的每一个流。在你把多个副本放到负载均衡器后面之前,这就是正确答案;因为到那时,客户端的流被固定在一个副本上,而另一个副本上的发布必须能到达它。
|
||||
|
||||
这个接缝由你来实现:在你的 pub/sub 后端之上写两个方法。
|
||||
|
||||
```python
|
||||
from collections.abc import Callable
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
|
||||
|
||||
|
||||
class RedisSubscriptionBus:
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
self._redis = redis
|
||||
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
|
||||
|
||||
async def publish(self, event: ServerEvent) -> None:
|
||||
await self._redis.publish("mcp-events", encode(event)) # to every replica
|
||||
|
||||
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
|
||||
token = object()
|
||||
self._listeners[token] = listener
|
||||
|
||||
def unsubscribe() -> None:
|
||||
self._listeners.pop(token, None)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
|
||||
```
|
||||
|
||||
`encode` 由你来写,每个副本上负责解码到达的消息并调用每个已注册 listener 的读取任务也是。listener 是同步的,不得抛出异常,并且在服务器的事件循环上运行。
|
||||
|
||||
总线承载的是带类型的 `ServerEvent` 值,四个小的 dataclass,从来不是 JSON-RPC。打标、过滤和流的生命周期都留在 SDK 里,所以总线实现无法破坏协议。它只能在进程之间搬运事件。
|
||||
|
||||
要在请求之外发布,就自己构造总线,这样你手里就有它的引用。不传任何东西时 `MCPServer` 会在内部建一个,并且不会暴露它。
|
||||
|
||||
```python
|
||||
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
|
||||
|
||||
bus = InMemorySubscriptionBus()
|
||||
mcp = MCPServer("Sprint Board", subscriptions=bus)
|
||||
|
||||
|
||||
async def tools_reloaded() -> None:
|
||||
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
|
||||
```
|
||||
|
||||
## 低层组合 {#the-low-level-composition}
|
||||
|
||||
在低层的 `Server` 上没有任何预先接好的东西,同样的部件三行就能组装起来:
|
||||
|
||||
```python title="server.py" hl_lines="8-9 47"
|
||||
--8<-- "docs_src/subscriptions/tutorial002.py"
|
||||
```
|
||||
|
||||
* 总线归你所有,所以直接向它发布:`await bus.publish(ResourceUpdated(uri=...))`。把它放在处理函数够得着的地方:这里是模块作用域,更大的应用里是生命周期。
|
||||
* `ListenHandler(bus)` 就是 `MCPServer` 注册的那个处理函数,`on_subscriptions_listen=` 是一个普通的处理函数槽位。在这个槽位里放你自己的可调用对象来实现不同的语义,规范上的义务就转到你身上:先确认,每一帧都打上订阅 id,不投递过滤器之外的任何东西。
|
||||
* `ListenHandler.close()` 优雅地结束每一个打开的流。每个流收到 listen 请求的结果作为最后一帧,这是规范表达“服务器有意结束了订阅”的方式。它在这些流完成刷新之前就返回,所以在拆掉传输之前给它们一点时间。没有它,流会在客户端断开时结束。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 客户端用一个 `subscriptions/listen` 请求选择加入,响应就是流。服务它是内置的。
|
||||
* 你用 `ctx.notify_*` 发布,SDK 负责打标、过滤和生命周期。
|
||||
* 事件是信号,不是载荷。两端都重新获取。
|
||||
* 客户端这一端是 `async with client.listen(...)`:详见“客户端”下的 **[订阅](../client/subscriptions.md)**。
|
||||
* 在低层的 `Server` 上你自己组装同样的部件:一个总线、`ListenHandler(bus)`、`on_subscriptions_listen` 槽位。
|
||||
* 横向扩展意味着实现 `SubscriptionBus`,两个方法,然后作为 `MCPServer(subscriptions=...)` 传入。
|
||||
|
||||
运行提供这一切的服务器,不管是一个副本还是二十个,见 **[部署与扩展](../run/deploy.md)**。
|
||||
Loading…
Reference in a new issue