docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
8 changed files with 89737 additions and 54 deletions
119
i18n/zh/pages/client/caching.md
Normal file
119
i18n/zh/pages/client/caching.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
translation:
|
||||
sections: [9e7b9a1710e5aeba, b74ca4c1d2ddddee, fa8714e61bf90c5a, 04db67a886b7271c, 857690fb8f876800]
|
||||
tool: 1
|
||||
---
|
||||
# 缓存提示 {#caching-hints}
|
||||
|
||||
在 2026-07-28 协议下,服务器为 `tools/list`、`prompts/list`、`resources/list`、`resources/templates/list`、`resources/read` 和 `server/discover` 返回的每个结果都带有两个字段:`ttlMs`,即客户端可以把结果视为新鲜的毫秒数;`cacheScope`,即缓存的结果可以在用户之间共享(`"public"`),还是只属于一个授权上下文(`"private"`)。
|
||||
|
||||
服务器本身不缓存任何东西。这些字段只是一种**声明**:“这个工具列表对所有人都一样,一分钟内不会变。”客户端(或挡在你前面的网关)随后可以省掉这次往返。是否遵从这些提示由客户端决定;发出它们是服务器的职责,而 SDK 替你做了这件事。
|
||||
|
||||
默认情况下,每个结果都是 `ttlMs: 0, cacheScope: "private"`:立即过期,从不共享。这永远安全,也永远合规。如果你的列表确实稳定、对所有调用者都相同,就在构造时声明:
|
||||
|
||||
```python title="server.py" hl_lines="5-8"
|
||||
--8<-- "docs_src/caching/tutorial001.py"
|
||||
```
|
||||
|
||||
* 这个映射以**方法名**为键,六个可缓存方法是唯一合法的键。参数类型是 `Mapping[CacheableMethod, CacheHint]`,所以编辑器会自动补全键名,并在运行前标出拼写错误;逃过类型检查器的值会在构造时抛出异常。
|
||||
* 没有提到的方法保留默认值。这个映射是一组覆盖项,不是清单。
|
||||
* `CacheHint(ttl_ms=5_000)` 没有设置 `scope`,所以它仍是 `"private"`:五秒的新鲜期,按调用者各自计算。作用域和 TTL 是相互独立的决定。
|
||||
* `"server/discover"` 也是合法的键,因为发现结果和任何列表一样可以缓存。
|
||||
|
||||
!!! warning
|
||||
`cacheScope: "public"` 意味着**任何人**都可能拿到你缓存的响应。共享网关会毫不犹豫地把一个用户的结果交给另一个用户,哪怕请求经过了认证。只有当结果对每个调用者都完全相同时才标记为 `"public"`,并且绝不要把 `cacheScope` 当作访问控制:它是标签,不是锁。
|
||||
|
||||
## 按处理函数覆盖 {#per-handler-override}
|
||||
|
||||
在底层 `Server` 上,处理函数手动构建结果,`ttl_ms` / `cache_scope` 只是结果模型上的字段。显式设置了它们的处理函数总是逐字段地优先于构造函数映射:
|
||||
|
||||
```python title="server.py" hl_lines="10 16"
|
||||
--8<-- "docs_src/caching/tutorial002.py"
|
||||
```
|
||||
|
||||
处理函数指定了 `ttl_ms=1_000`,对作用域只字未提。线路上是:`ttlMs: 1000`(处理函数的值,而不是映射里的 `60_000`)和 `cacheScope: "public"`(映射的值,因为处理函数没有设置)。显式优先于配置,配置优先于默认。这条规则按字段生效,所以处理函数可以固定一个字段,把另一个留给服务器范围的策略。
|
||||
|
||||
这也是构造函数无法预知的动态情况的出口:一个按用户过滤 `resources/read` 的处理函数,可以在其他方面都是 public 的服务器上为某个 URI 返回 `cache_scope="private"`。
|
||||
|
||||
关于分页列表有一点要注意:协议要求同一列表的**每一页 `cacheScope` 相同**。构造函数映射天然满足这一点,因为它按方法而不是按页作键。但自行覆盖作用域的处理函数要自己负责这种一致性:在**每一**页都覆盖,绝不要只在有游标时覆盖,否则第一页和第二页会不一致。
|
||||
|
||||
## 客户端看到什么 {#what-the-client-sees}
|
||||
|
||||
在 2026-07-28 会话上,`Client` 替你遵从这些提示:它内置了响应缓存,默认开启。带着 `ttlMs` 到达的结果会被存起来,在 TTL 内的相同调用直接由缓存提供,不发生往返。**不**带提示的结果不会被缓存:无提示的结果使用 `CacheConfig.default_ttl_ms`,它默认为 `0`(立即过期),所以什么都没声明的服务器看到的流量和以前一模一样,一次调用对应一次请求。
|
||||
|
||||
```python title="client.py" hl_lines="33 35 38"
|
||||
--8<-- "docs_src/caching/tutorial003.py"
|
||||
```
|
||||
|
||||
四次调用,三次抓取。第二次调用找到了新鲜条目,根本没到服务器;把(注入的)时钟拨过 TTL 让第三次重新抓取;第四次指定了 `cache_mode="refresh"`。这个关键字参数存在于五个缓存动词上(`list_tools`、`list_prompts`、`list_resources`、`list_resource_templates`、`read_resource`):
|
||||
|
||||
* `"use"`(默认)有新鲜条目就返回它,没有就抓取并存储。
|
||||
* `"refresh"` 从不返回缓存:它抓取并存储结果,替换掉原有缓存。
|
||||
* `"bypass"` 照常往返但完全不碰缓存:不读,不写。
|
||||
|
||||
有一条规则凌驾于 `"use"` 之上:**带 `meta` 的调用总会到达服务器。** 设置了 `meta`(进度令牌、追踪字段)的请求期望产生一次线路请求,所以在 `cache_mode="use"` 下它被当作 `"refresh"` 处理:跳过缓存读取,抓取到的结果仍会替换缓存条目。`"bypass"` 和显式的 `"refresh"` 行为照旧。
|
||||
|
||||
要完全关闭缓存,用 `Client(server, cache=None)` 构造:每次调用重新变成一次往返,`cache_mode` 虽然仍被接受,但不起作用。
|
||||
|
||||
作用域同样自动遵从:`"private"` 条目按缓存的**分区**(见下文)作键,而 `"public"` 条目可以选择更大范围的共享。并且对于通知点名的那些条目,**通知优先于 TTL**:`list_changed` 通知会驱逐对应的已缓存列表,`resources/updated` 会驱逐恰好存在其 URI 下的已缓存读取结果,不管它们多新鲜。在 2026-07-28 连接上,这些通知通过你用 `client.listen(...)` 打开的 `subscriptions/listen` 流到达,驱逐会在你的观察者看到事件之前完成;详见 **[订阅](subscriptions.md)**。
|
||||
|
||||
关于 `resources/updated` 有一点要注意:驱逐只针对精确匹配的 URI。存储契约没有枚举或扫描操作(与参考的 TypeScript 实现相同),所以携带**子**资源 URI 的通知不会驱逐其父资源的已缓存读取结果。如果你的服务器用这种方式通知子资源变化,就用 `cache_mode="refresh"` 重新抓取父资源。
|
||||
|
||||
### 配置:`CacheConfig` {#configuring-it-cacheconfig}
|
||||
|
||||
```python
|
||||
from mcp.client import CacheConfig
|
||||
|
||||
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
|
||||
```
|
||||
|
||||
* `store`:条目存放的位置。默认是每个客户端一个全新的内存存储;传入你自己的 `ResponseCacheStore` 实现(比如基于 Redis 的)即可在多个客户端或进程之间共享缓存。契约类型(`ResponseCacheStore`、`CacheKey`、`CacheEntry` 以及默认的 `InMemoryResponseCacheStore`)都可以从 `mcp.client` 导入。一次查找最多会对存储发出两次顺序的 `get`(先是 private 分支,然后是 public 分支),所以远程存储的延迟预期要据此估算。自定义存储**必须**显式指定 `partition`。
|
||||
* `partition`:授权上下文标签,防止在共享存储中把一个主体的 `"private"` 条目提供给另一个主体。
|
||||
* `target_id`:显式的服务器标识,用于自定义传输方式和进程内服务器(见下文)。
|
||||
* `default_ttl_ms`:应用于不带 `ttlMs` 提示的结果的 TTL。默认的 `0` 让无提示结果不被缓存。
|
||||
* `share_public`:跨分区提供服务器声明为 `"public"` 的条目(见下文)。默认关闭。
|
||||
* `clock`:墙上时钟来源,以纪元秒为单位。像上面的例子那样注入一个,过期测试就不需要 sleep 了。
|
||||
|
||||
!!! warning "分区 = 已验证的主体"
|
||||
从**已验证的凭证**派生 `partition`,比如经过校验的令牌的 subject。绝不要从请求提供的数据派生,也绝不要从服务器 URL 派生(服务器标识是单独的键维度)。SDK 是一个库,自身不做认证:信任锚点是构造 `CacheConfig` 的一方,也就是部署方,而不是租户。多租户网关为每个已认证主体创建一个 `CacheConfig`。
|
||||
|
||||
分区在 `Client` 的整个生命周期内也是固定的。如果连接的授权上下文在会话中途改变(比如以另一个主体重新认证),缓存不会跟着变;为新的主体构造一个新的 `Client`。
|
||||
|
||||
缓存键还携带**服务器的标识**:你连接的 URL 字符串,去掉其中的 `user:pass@` 用户信息,其余逐字节保持原样。不做大小写折叠,不重排查询参数,不清理末尾斜杠。规范化不足只会损失一些共享,而过度规范化可能合并两个租户(`?tenant=a` 对 `?tenant=b`),所以表面上不同的 URL 干脆不共享条目。没有 URL 时(进程内服务器,或 `Transport` 实例),客户端改用每个实例随机生成的标识;设置 `CacheConfig.target_id` 来给服务器命名(使用自定义存储时这是必需的,构造时会报错提示)。标识在进入键材料之前会经过 sha256 哈希,所以查询字符串里带有机密的 URL 永远不会出现在存储键中。你自己也不要记录哈希前的形式。
|
||||
|
||||
!!! warning "`share_public` 信任服务器,而且是全集群范围"
|
||||
默认情况下,即使是 `"public"` 条目也留在各自的分区内。`share_public=True` 会把服务器标记为 `cacheScope: "public"` 的条目提供给使用该存储的**每一个**分区,代表所有分区信任服务器的分类。如果服务器(因为 bug 或恶意)给按租户区分的数据打上 `"public"`,一个租户的响应就会泄漏给其他租户。这个标志刻意只放在构造函数层面:逐调用的 `cache_mode` 可以收窄缓存,但没有任何逐调用的方式能放宽共享。
|
||||
|
||||
### 缓存绝不会做的事 {#what-the-cache-never-does}
|
||||
|
||||
* **会话层的调用绕过它。** `client.session.list_tools()` 及同类方法总是发生往返;缓存位于 `Client` 的动词方法上。
|
||||
* **`server/discover` 不参与。** 发现结果在连接时交付一次,永远不进入响应缓存,即使它带有 `ttlMs`。如果你自己持久化一份来跳过重连探测([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)),它的新鲜度由你自己记账:`DiscoverResult` 正是为此携带了已解析好的 `ttl_ms` 和 `cache_scope`。
|
||||
* **续页永不缓存。** 只有不带游标的调用参与。因游标过期而被拒绝的续页确实会**驱逐**已缓存的列表,因为列表在它底下发生了变化。
|
||||
* **多轮往返(multi-round-trip)读取永不缓存。** 用 `input_responses`/`request_state` 作种子的 `read_resource`,或经过输入轮次才解析完成的读取,永远不进入缓存(规范中的 MUST)。
|
||||
* **通知驱逐需要通知。** 驱逐的效果取决于传输的投递能力,而现代的进程内路径(`Client(server)` 配合默认的 `mode="auto"`)目前不投递独立通知。
|
||||
* **驱逐是最终一致的,不是即时的。** 线路路径的通知由派生的任务分发,所以与通知到达竞态的调用可能再被提供一次驱逐前的条目;这个窗口受分发延迟限制,驱逐最终仍会生效。
|
||||
* **没有 stale-if-error。** 过期条目绝不会因为重新抓取失败而被提供;错误会向上传播。
|
||||
* **没有提前重新抓取。** 已存储的条目一直提供到 TTL 过期,之后的下一次调用承担往返开销;没有任何后台刷新。
|
||||
* **没有合并。** 两个并发的相同调用就是两次抓取。
|
||||
* **TTL 不超过 24 小时。** 更大的 `ttlMs`,无论是服务器发送的还是配置的,存储时都会被钳制(`mcp.client.caching.MAX_TTL_MS`),从而限制任何条目(无论提示多慷慨)能被提供多久。
|
||||
* 在**共享存储**上,客户端之间会相互竞态。当驱逐赶在进行中的抓取之前发生时,每个客户端会丢弃自己的写入,但**同租**的客户端仍可能把一个被它从未见过的驱逐移除的条目写回去;而这套竞态记账本身也有上限:跟踪的键超过 4096 个后,最旧的键的保护先被丢弃。这两个窗口都是可接受的,并由上面的 TTL 上限兜底关闭。
|
||||
* **不跨协议时代提供。** 条目按协商的协议版本划定范围:在共享的持久存储上,会话绝不会提供在另一个协商版本下写入的条目(同一份列表在不同时代确实不同,因为 SDK 会为旧会话剥离 2026 的字段)。驱逐同样只触及当前时代的条目;其他时代的条目只是随 TTL 自然过期。
|
||||
|
||||
### 自己读取提示 {#reading-the-hints-yourself}
|
||||
|
||||
这些提示也是每个可缓存结果上的普通字段(`result.ttl_ms` 和 `result.cache_scope`,已解析好),方便你在内置缓存之上(或代替它)叠加自己的记账逻辑。
|
||||
|
||||
面对**旧服务器**(2026 之前的协议),这些字段在线路上根本不存在,模型显示的是保守的默认值:`ttl_ms == 0` 和 `cache_scope == "private"`,过期且不共享,对于什么都没声明的服务器这正是正确的假设。缓存对旧会话一视同仁:在那里从不参考提示(不管线路上出现什么键),只有 `default_ttl_ms` 生效,而它的默认值 `0` 什么都不缓存,所以 2026 之前的连接行为和缓存出现之前完全一样。如果需要区分“服务器说了 0”和“服务器什么都没说”,检查 `"ttl_ms" in result.model_fields_set`:只有字段确实到达时它才会被设置。
|
||||
|
||||
## 旧客户端 {#older-clients}
|
||||
|
||||
使用 2026 之前协议版本的客户端永远看不到这两个字段;SDK 会在序列化时为这些连接剥离它们。提示只需配置一次,没有任何版本相关的代码要写。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 六个方法携带 `ttlMs`/`cacheScope`;SDK 默认它们为 `0`/`"private"`,过期且不共享,永远安全。
|
||||
* 构造时的 `cache_hints={method: CacheHint(...)}`(`MCPServer` 和 `Server` 都支持)按方法设置服务器范围的值。
|
||||
* 在结果上设置了这些字段的处理函数会逐字段覆盖映射。
|
||||
* `"public"` 是一个承诺:结果对每个调用者都相同。它不是访问控制。
|
||||
* `Client` 自动遵从提示:它的响应缓存默认开启,提供新鲜条目而不是重新抓取,对不提供提示的服务器(或会话)什么都不缓存。
|
||||
* 逐调用地,`cache_mode="refresh"` 重新抓取,`"bypass"` 跳过缓存;构造时 `cache=None` 则完全关闭它。
|
||||
142
i18n/zh/pages/client/callbacks.md
Normal file
142
i18n/zh/pages/client/callbacks.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
translation:
|
||||
sections: [adf3c545b5be46b6, 916cd3ab1c03f461, e9be7a8d0eb0a456, 565890a636288ecf, 6af7e49db9129ec3, 06b0238c174186af, 90c6043be435fcb0]
|
||||
tool: 1
|
||||
---
|
||||
# 客户端回调 {#client-callbacks}
|
||||
|
||||
MCP 里几乎所有请求都是单向的:从客户端发往服务器。
|
||||
|
||||
服务器也可以反过来向**客户端**要东西:向用户提一个问题、借用用户的模型做采样(sampling)、列出用户的工作区文件夹。要回答这些请求,把**回调**传给 `Client(...)` 即可。
|
||||
|
||||
## 一个会提问的服务器 {#a-server-that-asks}
|
||||
|
||||
下面这个服务器的工具没法独立完成任务:
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/client_callbacks/tutorial001.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit(...)` 向**客户端**发送一个 `elicitation/create` 请求,然后等待。
|
||||
* 在有人(填表单的人,或者你的代码)给出 `name` 之前,这个工具不会返回。
|
||||
|
||||
这是服务器那一半,归 **[征询](../handlers/elicitation.md)** 页面管。本页讲的是线路的另一端。
|
||||
|
||||
## 征询回调 {#the-elicitation-callback}
|
||||
|
||||
```python title="client.py" hl_lines="6-10 16-17"
|
||||
--8<-- "docs_src/client_callbacks/tutorial002.py"
|
||||
```
|
||||
|
||||
* 征询(elicitation)回调的签名是 `async (context, params) -> ElicitResult`。
|
||||
* `params.message` 是问题本身。`params.requested_schema` 是服务器想要的答案的 JSON Schema。真正的客户端会据此渲染一个表单;这里的客户端直接自动填好。
|
||||
* 返回 `ElicitResult(action="accept", content={...})`,或者 `action="decline"`,或者 `action="cancel"`。除此之外唯一的选择是 `ErrorData(...)`,它会拒绝这个请求,并让整个调用失败。
|
||||
* `context` 是一个 `ClientRequestContext`:包含活动的 `session`、服务器的 `request_id`,以及它附带的 `meta`(如果有)。
|
||||
|
||||
!!! tip
|
||||
`params` 是两种征询模式的联合类型。这里 `params.mode` 是 `"form"`;`"url"` 请求携带的是 `params.url` 而不是模式(schema)。一个回调同时处理两种情况,按 `params.mode` 分支即可。完整写法见 **[征询](../handlers/elicitation.md)**。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
调用 `issue_card`,观察两端。
|
||||
|
||||
你的回调收到服务器的问题,已经解析好了:
|
||||
|
||||
```python
|
||||
params.mode # 'form'
|
||||
params.message # 'What name should go on the card?'
|
||||
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
|
||||
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
|
||||
```
|
||||
|
||||
回调给出回答,`ctx.elicit(...)` 在工具内部恢复执行,工具随即完成:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
|
||||
```
|
||||
|
||||
你发出一个 `tools/call`,服务器回过来一个 `elicitation/create`,由你的函数作答——全部发生在一次工具调用之内。
|
||||
|
||||
!!! info
|
||||
`Client(...)` 调用里的 `mode="legacy"` 是真正起作用的。默认情况下 `Client(...)` 协商的是现代协议路径,而那条路径没有供服务器向客户端发请求的反向通道(back-channel):`ctx.elicit` 会在你的回调运行之前就失败。决定这一点的不是传输方式,而是协商出的协议,内存传输和 URL 传输都一样。只要你的客户端需要回答这类请求,就固定使用 `mode="legacy"`;本页背后的每个测试都是这么做的。详见 **[协议版本](../protocol-versions.md)**。
|
||||
|
||||
在 2026-07-28 会话上,这个回调并没有失效,只是触发方式不同:当工具返回一个携带 `ElicitRequest` 的 `InputRequiredResult` 时,`Client` 会把该条目分派给同一个 `elicitation_callback`,并替你重试这次调用。那个流程见 **[多轮往返请求](../handlers/multi-round-trip.md)**。
|
||||
|
||||
## 回调就是能力 {#a-callback-is-a-capability}
|
||||
|
||||
你从没告诉服务器你的客户端能回答征询请求。是 SDK 说的。
|
||||
|
||||
客户端连接时会声明自己的 `capabilities`,和服务器的能力互为镜像。这个对象不用你写。**注册回调就是声明。**
|
||||
|
||||
| 你传入 | 客户端声明 |
|
||||
| --- | --- |
|
||||
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
|
||||
| `sampling_callback=` | `"sampling": {}` |
|
||||
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
|
||||
| 一个都不传 | `{}` |
|
||||
|
||||
采样的子能力是唯一需要细化的地方:如果你的采样器能处理 `tools` / `tool_choice` 参数,就在传入 `sampling_callback` 的同时传入 `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())`。服务器必须先看到 `sampling.tools` 已声明,才能发送这些参数。
|
||||
|
||||
`logging_callback` 和 `message_handler` 不在表里。它们处理的是通知,而通知不需要能力。
|
||||
|
||||
服务器用 `ctx.session.check_client_capability(...)` 读回这份声明。加一个这样的工具:
|
||||
|
||||
```python title="server.py" hl_lines="23-31"
|
||||
--8<-- "docs_src/client_callbacks/tutorial003.py"
|
||||
```
|
||||
|
||||
只带 `elicitation_callback` 连接并调用它:
|
||||
|
||||
```python
|
||||
result.structured_content # {'result': ['elicitation']}
|
||||
```
|
||||
|
||||
三个回调都传,返回 `['elicitation', 'sampling', 'roots']`。一个都不传,返回 `[]`。
|
||||
|
||||
!!! check
|
||||
现在故意做错:**不带** `elicitation_callback` 连接,照样调用 `issue_card`。
|
||||
|
||||
服务器的 `elicitation/create` 请求仍然会到达你的客户端,而 SDK 会替你作答——用一个错误,因为你从没说过自己能处理它。这个错误会拖垮整个调用。`call_tool` 不会返回一个 `is_error` 结果,而是直接抛出异常:
|
||||
|
||||
```text
|
||||
MCPError: Elicitation not supported
|
||||
```
|
||||
|
||||
这是协议错误(`-32600`,“invalid request”),不是工具错误:没有任何东西可供模型读取并重试。这正是 `client_features` 值得拥有的原因:行为规范的服务器会先检查再提问。
|
||||
|
||||
## 已弃用的那一对 {#the-deprecated-pair}
|
||||
|
||||
`sampling_callback` 回答 `sampling/createMessage`:服务器请求**你的**模型补全一些内容。`list_roots_callback` 回答 `roots/list`:服务器询问它可以在哪些目录(根目录(roots))里工作。
|
||||
|
||||
两者都能用,也都遵循上面的规则。但两者服务的 RPC 都被 **2026-07-28 规范移除了**:现代服务器不会在请求中途回调你的客户端,而是把请求作为工具结果的一部分交还给你(**[多轮往返请求](../handlers/multi-round-trip.md)**,即多轮往返(multi-round-trip))。回调本身并没有失效。当 `InputRequiredResult` 携带 `CreateMessageRequest` 或 `ListRootsRequest` 时,`Client` 的自动循环会把它分派给你在这里注册的同一个 `sampling_callback` 或 `list_roots_callback`。完整清单见 **[已弃用的功能](../deprecated.md)**。
|
||||
|
||||
要和还没迁移的服务器通信,仍然需要这些回调。签名如下:
|
||||
|
||||
```python title="client.py"
|
||||
--8<-- "docs_src/client_callbacks/tutorial004.py"
|
||||
```
|
||||
|
||||
* 采样回调收到完整的 `CreateMessageRequestParams`(`messages`、`model_preferences`、`max_tokens`),返回一个 `CreateMessageResult`。模型由**你**来运行,怎么运行都行;SDK 只负责传递请求。
|
||||
* 根目录回调完全不接收参数,返回一个 `ListRootsResult`。
|
||||
* 两者都可以改为返回 `ErrorData(...)` 来表示拒绝。
|
||||
|
||||
把它们传给 `Client(...)`,方式和 `elicitation_callback` 完全一样。
|
||||
|
||||
## 通知回调 {#the-notification-callbacks}
|
||||
|
||||
还有两个。它们都不声明任何东西。
|
||||
|
||||
`logging_callback` 接收服务器发送的 `notifications/message`,形式是 `LoggingMessageNotificationParams`(`level`、`logger`、`data`)。协议日志本身已被 2026-07-28 规范弃用(替代做法见 **[日志](../handlers/logging.md)**),所以这个回调是为仍在发出这类消息的服务器准备的。在 2026 年代的连接上,单有回调什么也收不到,因为 2026 服务器只向主动选择接收的请求发送日志消息:给 `Client(...)` 传入 `log_level="info"`(或其他级别),就会在每个请求上打上这个选择标记,并收到该级别及以上的消息。2026 之前的服务器会忽略它,保持原有的 `logging/setLevel` 行为。
|
||||
|
||||
`message_handler` 是兜底的:会话浮现出来的每一个服务器通知都会到达它(同时也到达各自专门的回调),在基于流的传输上,每一个传输层的 `Exception` 也是如此。有两种永远不会到达:`notifications/cancelled` 由 SDK 直接应用而不浮现出来;针对活动 `listen()` 流的订阅确认则由该流自己消费。给这个参数标注 `IncomingMessage` 类型(`ServerNotification | Exception`,从 `mcp.client` 导出)。唯一值得记住的写法是 `if isinstance(message, Exception): raise message`,这样断开的连接会大声报错,而不是悄悄消失。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 服务器可以向客户端发送请求。用传给 `Client(...)` 的回调来回答它们。
|
||||
* 征询回调是当前仍在使用的那个:`async (context, params) -> ElicitResult`,一个函数同时处理表单模式和 URL 模式。
|
||||
* **注册回调就是声明能力。**没有它,SDK 会替你拒绝服务器的请求,整个调用以 `MCPError` 失败。
|
||||
* 服务器用 `ctx.session.check_client_capability(...)` 在提问前先查明。
|
||||
* `sampling_callback` 和 `list_roots_callback` 的工作方式相同,但服务的是已弃用的功能;现代服务器改用多轮往返请求。
|
||||
* `logging_callback` 和 `message_handler` 接收通知。它们不声明任何东西。
|
||||
|
||||
`Client(...)` 的第一个参数是传输对象。**[客户端传输](transports.md)** 涵盖了每一种。
|
||||
129
i18n/zh/pages/client/identity-assertion.md
Normal file
129
i18n/zh/pages/client/identity-assertion.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
translation:
|
||||
sections: [a91322c46111d16d, 8e6fd6d6f59bb568, e7828fd2729b2c9d, a03ec26bfc678b65, 1034c653c0bcf1b0]
|
||||
tool: 1
|
||||
---
|
||||
# 身份断言 {#identity-assertion}
|
||||
|
||||
普通的 OAuth provider(**[OAuth 客户端](oauth-clients.md)**)一开始会问 MCP 服务器一个问题:“你信任哪个授权服务器?”答案指向哪里,它就跟到哪里,然后要么有人登录,要么用一个预共享密钥代替人登录。
|
||||
|
||||
企业不希望这两件事按服务器逐个决定。它已经在运行一个身份提供方(Okta、Microsoft Entra ID,或者自建的);用户今天早上已经登录过它了;而且安全团队只想在这一个地方决定谁能访问什么。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990),即 **Enterprise-Managed Authorization** 扩展,把这个决定移到了那里。IdP 签发一个短期有效的 JWT,即 **Identity Assertion JWT Authorization Grant**,简称 **ID-JAG**:它表明**这个用户**经由**这个客户端**可以访问**这个 MCP 服务器**。客户端用它换取一个普通的访问令牌。没有浏览器,没有同意页面,没有动态注册。
|
||||
|
||||
本页讲的是这笔交换的两端。MCP 服务器本身完全不变:它仍然是 **[授权](../run/authorization.md)** 里的那个资源服务器,检查收到的任何令牌。
|
||||
|
||||
## 两次令牌请求 {#two-token-requests}
|
||||
|
||||
这里涉及两个不同的权威方,把它们区分清楚,这一页就懂了大半。**企业 IdP** 是你所在组织的身份提供方:它知道员工是谁,策略定在它那里,ID-JAG 由它签发。SDK 从不和它通信。**MCP 授权服务器**还是 **[授权](../run/authorization.md)** 里的那个角色:MCP 服务器元数据里指明的 issuer,负责铸造这个 MCP 服务器接受的令牌。在普通的 OAuth 流程里,这两个角色通常是同一个系统。在这里它们是两个,而整个授权许可的核心,就是后者同意信任前者。
|
||||
|
||||
客户端向它们各发一次令牌请求。
|
||||
|
||||
1. **发给企业 IdP。** 客户端用用户的登录(他们的 OpenID Connect ID token)换取 ID-JAG。这是一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 令牌交换,完全是你 IdP 的 API,**SDK 不发这个请求**。由你来发,在一个异步回调里。策略决定也发生在这里:IdP 说不,就根本不会签发 ID-JAG,也就没有东西可出示。
|
||||
2. **发给 MCP 授权服务器。** 客户端按 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授权许可出示 ID-JAG(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`,ID-JAG 作为 `assertion`),拿到访问令牌。**这是 SDK 发的请求**,而接受它,就是本页给授权服务器加的唯一一样东西。
|
||||
|
||||
下面的内容全是第二个请求:发送它的客户端,和回应它的授权服务器。
|
||||
|
||||
## 客户端 {#the-client}
|
||||
|
||||
**`IdentityAssertionOAuthProvider`** 位于 `mcp.client.auth.extensions.identity_assertion`。和 **[OAuth 客户端](oauth-clients.md)** 里的每个 provider 一样,它是一个 `httpx2.Auth`:构造一个,放到 `auth=` 上,把 `httpx2.AsyncClient` 交给传输。
|
||||
|
||||
```python title="client.py" hl_lines="49-50 53-61"
|
||||
--8<-- "docs_src/identity_assertion/tutorial001.py"
|
||||
```
|
||||
|
||||
从下往上读。
|
||||
|
||||
* `main()` 就是标准的 OAuth 客户端 `main()`(**[OAuth 客户端](oauth-clients.md)**),一行都没改。重点就在这:一旦 provider 存在,下游没有任何东西知道令牌是哪种授权许可产生的。
|
||||
* provider 接收的是其他 provider 无法自行发现的东西:有人在授权服务器上**预先注册**好的 `client_id` 和 `client_secret`、该授权服务器的 `issuer`,以及 `assertion_provider`——一个按需返回新鲜 ID-JAG 的异步回调。
|
||||
* `storage` 还是那个 `TokenStorage` 协议。只会调用那两个令牌方法;这里没有动态注册,所以也没有 `client_info` 需要记住。
|
||||
|
||||
### 断言提供函数 {#the-assertion-provider}
|
||||
|
||||
`fetch_id_jag(audience, resource)` 是唯一需要你写的代码。每次令牌交换时 await 一次,构造时绝不会调用,而且只在授权服务器的元数据取回并校验通过**之后**才调用,所以配错的 issuer 永远不会泄露断言。它的两个参数是铸造 ID-JAG 时必须带上的两个声明(claim):`audience` 是授权服务器的 issuer(ID-JAG 的 `aud`),`resource` 是 MCP 服务器的规范标识符(ID-JAG 的 `resource`)。第三个你手里已经有了:ID-JAG 的 `client_id` 声明必须写明你传给 provider 的那个 `client_id`,否则授权服务器会拒绝交换。
|
||||
|
||||
它上面的 `idp_issue_id_jag` **不是你的代码**。它替身份提供方出场,在进程内签发断言,这样文件是完整的,你也能读到 ID-JAG 携带的每一个声明。真正的 `fetch_id_jag` 发的是上一节里的第一个令牌请求:对你的 IdP 做一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 令牌交换,由 Identity Assertion JWT Authorization Grant 草案定义,[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 正是该草案的一个 profile。已登录用户的 ID token 作为 `subject_token` 传入,`requested_token_type` 是 ID-JAG 自己的 URN(`urn:ietf:params:oauth:token-type:id-jag`),`audience` 和 `resource` 原样透传,响应里带回 ID-JAG。在你 IdP 的文档里要找的,就是这些名字下的这次交换。
|
||||
|
||||
!!! tip
|
||||
每次交换都会请求一个新的 ID-JAG,这正是设计意图:它是一次性的、只活几分钟的授权许可,本页的授权服务器拒绝接受同一个 ID-JAG 两次。不要缓存它。该复用的是它换来的访问令牌。
|
||||
|
||||
### issuer 是配置项 {#the-issuer-is-configuration}
|
||||
|
||||
反转就在这里。`OAuthClientProvider` 会问资源服务器该用哪个授权服务器,答案指向哪里就跟到哪里。这个 provider 拒绝这么做:`issuer` 是必填的,[RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) 元数据从这个 issuer 自己的 well-known 路径获取,令牌端点必须在这个 issuer 的源(origin)上,而且从不向资源服务器询问任何事。
|
||||
|
||||
扩展本身并不要求这样;这是刻意做得更严格的选择。这个客户端带着两样值得偷的东西:一个预注册的密钥和一个绑定了 audience 的断言。如果客户端任由一个被攻破的 MCP 服务器把它引向攻击者的授权服务器,这两样就都会 POST 过去。在构造时钉死 issuer,这段对话就不存在了。
|
||||
|
||||
!!! warning
|
||||
配置的 `issuer` 会按 RFC 8414 §3.3 的简单字符串比较与元数据文档的 `issuer` 字段对比:逐字符比较,末尾斜杠算在内,不做任何规范化。不要猜。从你的授权服务器获取 `/.well-known/oauth-authorization-server`,把它返回的 `issuer` 值照抄过来。对本页的授权服务器来说,这个值是 `https://auth.example.com/`,带斜杠,因为它的 issuer 是从 Pydantic 的 URL 对象构建的。不匹配的话,流程会停在 `OAuthFlowError: Authorization server metadata issuer
|
||||
mismatch`,此时一条凭据或断言都还没有发出。
|
||||
|
||||
### 机密客户端 {#a-confidential-client}
|
||||
|
||||
`client_secret` 是必填的;没有它,构造函数会抛出 `ValueError`。[SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 底下的 IETF profile 把这种授权许可留给机密客户端,SEP-990 要求客户端进行身份认证,而这个 SDK 通过坚持要求共享密钥来同时落实这两点。`token_endpoint_auth_method` 决定它走哪条路:`client_secret_post`(默认,放在表单体里)或 `client_secret_basic`(HTTP Basic 头)。该 profile 还允许 `private_key_jwt`;这个 provider 不支持。
|
||||
|
||||
!!! tip
|
||||
从环境变量或密钥管理器读取 `client_secret`,永远不要从源码仓库里读。
|
||||
|
||||
### provider 替你做了什么 {#what-the-provider-does-for-you}
|
||||
|
||||
第一个请求不带认证发出,服务器的 `401` 启动整个流程。
|
||||
|
||||
1. **发现。** 从配置的 issuer 的 [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known 路径获取授权服务器元数据,检查文档的 `issuer` 是否匹配,并检查令牌端点是否在 issuer 的源上。
|
||||
2. **断言。** await 你的 `assertion_provider`。
|
||||
3. **交换。** 把 `jwt-bearer` 授权许可 POST 到令牌端点,存下 `OAuthToken`,然后带上 `Authorization: Bearer ...` 重放你原来的请求。
|
||||
|
||||
如果收到的 `403` 的 `WWW-Authenticate` 指明 `insufficient_scope`,会用你的 `scope` 与质询中的 scope 的并集重新执行第 2、3 步。(`scope` 从来都只是请求;本页的授权服务器只授予 ID-JAG 写明的内容,别的一概不给。)整个过程里没有刷新令牌:访问令牌过期后,下一个 `401` 会铸造一个新的 ID-JAG 再次交换,**这**正是 IdP 手里握着的杠杆。失败时的异常和 **[OAuth 客户端](oauth-clients.md)** 其余部分一样是那两个:发现和校验阶段是 `OAuthFlowError`,令牌端点拒绝时是它的子类 `OAuthTokenError`。
|
||||
|
||||
## 授权服务器 {#the-authorization-server}
|
||||
|
||||
大多数时候到这里就可以停了。MCP 授权服务器是别人的产品,接受 ID-JAG 是它那边要打开的配置,SDK 负责的那一半 [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 就是上面的客户端。
|
||||
|
||||
SDK 也可以自己**充当**授权服务器:`create_auth_routes` 以列表形式返回授权服务器的路由,任何 Starlette 应用都能挂载,仓库里的 `examples/servers/simple-auth/` 就是这样跑起一个的。SEP-990 给这个接口面加了一个开关和一个方法:
|
||||
|
||||
```python title="auth_server.py" hl_lines="48-50 105-107"
|
||||
--8<-- "docs_src/identity_assertion/tutorial002.py"
|
||||
```
|
||||
|
||||
* `identity_assertion_enabled=True` 是总开关。关闭时(这是默认),即使你实现了钩子,`/token` 对这种授权许可也回答 `unsupported_grant_type`,元数据里也不会提到它。打开后,元数据会多出 `jwt-bearer` 授权类型,并在 `authorization_grant_profiles_supported` 里列出 `urn:ietf:params:oauth:grant-profile:id-jag`,这是扩展用来宣告支持的字段。(这个 SDK 的客户端从不读它:它只为一个 issuer 配置,直接发请求就是了。)
|
||||
* **`exchange_identity_assertion`** 就是那个钩子。它运行之前,SDK 已经认证了客户端,拒绝了公开客户端,也拒绝了注册信息里没有列出该授权许可的客户端。你拿到一个 `IdentityAssertionParams`(原始的 `assertion`、请求的 `scopes` 和 `resource`),返回一个普通的 `OAuthToken`。
|
||||
* 动态客户端注册无条件拒绝这种授权许可,所以这里的 `get_client` 提供的是一个手工配置的客户端。ID-JAG 客户端没法靠自我注册凭空出现。
|
||||
* 这个类有一半是拒绝。`OAuthAuthorizationServerProvider` 是**整个**授权服务器,所以它也要求实现授权码流程;一个同时让用户登录的服务器会真正实现那些方法,而这一个只开一扇门。
|
||||
|
||||
!!! warning
|
||||
SDK 从不解码断言:只有你的部署知道它信任哪个 IdP、那个 IdP 发布哪些密钥,所以 `exchange_identity_assertion` 里的每一步都是承重的。按 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3,用 IdP 发布的密钥(它的 JWKS;这里的共享密钥只是演示用的)验证签名,并校验 `iss` 和 `exp`。要求 JWT 头的 `typ` 为 `oauth-id-jag+jwt`,这是 profile 防止别的 JWT 被当作授权许可重放的防护。要求 `aud` 是你自己的 issuer。要求 ID-JAG 的 `client_id` 声明等于处理函数认证过的那个客户端,它的 `resource` 声明指明一个你确实提供的资源。跟踪 `jti` 直到断言的 `exp`,保证它只被接受一次。授予的 scope,尤其是所签发令牌的 `resource`,要取自校验过的 ID-JAG,绝不取自请求:`params.resource` 是客户端随手填的。完整的处理规则见 [Enterprise-Managed Authorization 规范](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)。
|
||||
|
||||
用 `TokenError("invalid_grant", ...)` 拒绝不合格的断言。这个流程里另一个错误码是 `invalid_target`:指明了你不提供的资源的 ID-JAG 就用它拒绝,正是它阻止了这个服务器为别人的资源铸造令牌。授予的 scope 来自 ID-JAG 的 `scope` 声明(没有这个声明的断言同样会被拒绝);你的实现也许会改为映射用户所属的组。
|
||||
|
||||
再注意返回的 `OAuthToken` 里没有什么:刷新令牌。IdP 通过决定是否签发下一个 ID-JAG 来决定这个用户能访问多久。在这里铸造刷新令牌,等于悄悄把这个决定权交了回去。
|
||||
|
||||
!!! info
|
||||
仍然用 `auth_server_provider=` 内嵌授权服务器的服务器,通过 `AuthSettings(identity_assertion_enabled=True)` 走到同一段代码。**[授权](../run/authorization.md)** 解释了为什么新服务器不应该从那里起步。
|
||||
|
||||
!!! check
|
||||
把本页的两个文件接在一起,整个授权许可就是一次 `POST /token`:
|
||||
|
||||
```text
|
||||
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
||||
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
|
||||
client_id=finance-agent
|
||||
resource=http://localhost:8001/mcp
|
||||
scope=notes:read
|
||||
client_secret=finance-agent-secret
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}
|
||||
```
|
||||
|
||||
没有 `/authorize`,没有 `/register`,没有获取 protected resource metadata。线路上仅有的请求是引出 `401` 的那个、well-known 获取、这次交换,然后就是带着 bearer 的普通 MCP 流量。而你的校验器从 ID-JAG 里读出的 `sub`,正是工具内部 `get_access_token().subject` 报告的值。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
SDK 仓库里的 `examples/stories/identity_assertion/` 就是本页真实跑起来的样子:同一个 `exchange_identity_assertion` 校验器、一个靠它的令牌把关的 MCP 服务器、一个替身 IdP,还有客户端,都在一个自检程序里。`uv run python -m stories.identity_assertion.client --http` 会跑完整个交换,并断言 IdP 指明的用户就是工具看到的用户。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) 让企业身份提供方而不是最终用户来决定客户端可以访问哪些 MCP 服务器。IdP 把这个决定签进一个 **ID-JAG**。
|
||||
* 获取 ID-JAG 是对**你的 IdP** 做的一次 [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) 令牌交换,SDK 不做这一步。向 MCP 授权服务器出示它是 [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) 的 `jwt-bearer` 授权许可,这一步的两端 SDK 都做。
|
||||
* `IdentityAssertionOAuthProvider` 又是一个 `httpx2.Auth`:一个预注册的机密客户端、一个钉死的 `issuer`,加一个 `assertion_provider(audience, resource)` 回调。没有浏览器,没有注册,没有刷新令牌。
|
||||
* 授权服务器永远不会从资源服务器发现。把 `issuer` 配置成与它的元数据文档提供的字符串完全一致;比较是逐字符的。
|
||||
* 服务器端是 `identity_assertion_enabled=True` 加 `exchange_identity_assertion`。SDK 认证客户端并为授权许可把关;校验 ID-JAG 完全是你的事,签发的令牌绑定到 ID-JAG 的 `resource`,而不是请求里的。
|
||||
|
||||
本页唯一没碰过的一方是 MCP 服务器。它怎么处理你刚铸造的令牌,在 **[授权](../run/authorization.md)** 里早就在做了。
|
||||
207
i18n/zh/pages/client/index.md
Normal file
207
i18n/zh/pages/client/index.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
---
|
||||
translation:
|
||||
sections: [ebef1e7a0df854f4, a4c687d3d627d516, 8e79141fc2985342, b345dd05b9c3c7ab, 80ce41579825a6fa, 5f0fa90494de8f65, 83d10514eaa62fa5, 9190555aa39a5d28, 84a4c9d8bf14dddb, 927d71cf40b58c30]
|
||||
tool: 1
|
||||
---
|
||||
# Client {#the-client}
|
||||
|
||||
**`Client`** 是 Python 程序与 MCP 服务器对话的方式。
|
||||
|
||||
它是一个对象,只有一套生命周期:构造它,进入 `async with`,然后调用方法。每个协议动词(列出工具、调用工具、读取资源、渲染提示词)都是它上面的一个 `async` 方法,返回带类型的结果。
|
||||
|
||||
## 你的第一个客户端 {#your-first-client}
|
||||
|
||||
```python title="client.py" hl_lines="14-18"
|
||||
--8<-- "docs_src/client/tutorial001.py"
|
||||
```
|
||||
|
||||
顶部的服务器只是为了让你有东西可连。客户端就是高亮的那五行。
|
||||
|
||||
* `Client(mcp)` 接收的是**服务器对象本身**。这是内存传输:没有子进程,没有端口,没有 HTTP。本页的每个示例,以及你写的每个测试,都是这样连接的。
|
||||
* `async with` 就是**生命周期**。进入时连接并协商;离开时断开。没有 `connect()` / `close()` 这样的配对方法,而且 `Client` 在代码块结束后不能复用。
|
||||
* 在代码块内部,连接相关的信息已经作为普通属性摆在那里了。
|
||||
|
||||
### 可以传给 `Client` 什么 {#what-you-can-pass-to-client}
|
||||
|
||||
`Client` 接收一个位置参数,并根据它的类型确定传输方式:
|
||||
|
||||
* `MCPServer`(或低层 `Server`)实例:**进程内**连接。
|
||||
* URL 字符串(`Client("http://localhost:8000/mcp")`):Streamable HTTP,生产环境的路径。
|
||||
* **传输**:任何可以 `async with ... as (read, write)` 的对象,比如包装子进程的 `stdio_client(...)`。
|
||||
|
||||
本页其余内容在这三种方式下完全相同。请求头、子进程、超时以及 `Transport` 协议另有专页:**[客户端传输](transports.md)**。
|
||||
|
||||
### 已连接的客户端上有什么 {#whats-on-a-connected-client}
|
||||
|
||||
四个只读属性,进入代码块的那一刻就已填好:
|
||||
|
||||
* `client.server_info`:服务器的身份信息;对于不报告身份的 2026 时代服务器则为 `None`(python-sdk 服务器默认会报告)。这里 `server_info.name` 是 `"Bookshop"`,`server_info.version` 是服务器报告的版本。
|
||||
* `client.server_capabilities`:服务器能做什么(`tools`、`resources`、`prompts`、`completions`……)。服务器没有的能力是 `None`。
|
||||
* `client.protocol_version`:双方商定的协议版本。这里是 `"2026-07-28"`。
|
||||
* `client.instructions`:服务器的 `instructions=` 字符串,没设置则为 `None`。
|
||||
|
||||
你从没选过协议版本。默认情况下,`Client` 会探测服务器,遇到较老的服务器就回退到经典握手,所以一个客户端能对接任何时代的服务器。需要控制这一点时,详见 **[协议版本](../protocol-versions.md)**。
|
||||
|
||||
!!! tip
|
||||
`client.session` 是底层的 `ClientSession`,即低层的逃生出口。本页的任何内容都用不到它。
|
||||
|
||||
## 列出工具 {#listing-tools}
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial002.py"
|
||||
```
|
||||
|
||||
`list_tools()` 返回 `ListToolsResult`;工具在 `.tools` 里。每一个都是宿主会交给模型的完整定义:
|
||||
|
||||
```python
|
||||
tool.name # 'search_books'
|
||||
tool.title # 'Search the catalog'
|
||||
tool.description # 'Search the catalog by title or author.'
|
||||
```
|
||||
|
||||
而 `tool.input_schema` 是服务器从函数类型注解推导出的 JSON Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
UI 渲染参数表单所需的一切,以及模型生成合法参数所需的一切,都在这个模式里。
|
||||
|
||||
!!! tip
|
||||
`title` 是可选的,所以把工具展示给人看的 UI 必须做选择:有 `title` 就用它,没有就用 `name`。`from mcp.shared.metadata_utils import get_display_name` 做的正是这件事,适用于工具、资源、资源模板和提示词。
|
||||
|
||||
## 调用工具 {#calling-a-tool}
|
||||
|
||||
`call_tool(name, arguments)` 运行工具,返回 `CallToolResult`。
|
||||
|
||||
```python title="client.py" hl_lines="26-33"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
服务器的 `lookup_book` 返回一个 Pydantic `Book`。客户端看到的是这样的:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
|
||||
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
|
||||
result.is_error # False
|
||||
```
|
||||
|
||||
一个返回值,三样东西要读。各自有不同的使用者。
|
||||
|
||||
### `content`:模型读的内容 {#content-what-the-model-reads}
|
||||
|
||||
`content` 是一个**内容块**的 `list`,而内容块是一个联合类型:`TextContent`、`ImageContent`、`AudioContent`、`ResourceLink` 或 `EmbeddedResource`。一个工具可以返回多个不同种类的块。
|
||||
|
||||
这就是为什么 `main` 在碰 `block.text` 之前先用 `isinstance(block, TextContent)` 收窄类型。注意 `isinstance` 之外没有出现 `.text`:类型检查器不允许,因为 `ImageContent` 有的是 `.data`,不是 `.text`。这个联合类型如实表达了工具可以发给你什么;你的代码也应该如此。
|
||||
|
||||
### `structured_content`:应用程序读的内容 {#structured_content-what-your-application-reads}
|
||||
|
||||
`structured_content` 是工具返回值的 JSON 形式,符合工具声明的 `output_schema`。不用解析字符串,不用猜。
|
||||
|
||||
两者同时存在时,是有意把同一件事说两遍:`content` 给模型,`structured_content` 给代码。结构化这一半从哪里来、如何控制,见 **[结构化输出](../servers/structured-output.md)** 页面。
|
||||
|
||||
### `is_error`:工具是否失败 {#is_error-whether-the-tool-failed}
|
||||
|
||||
抛出异常的工具**不会**在客户端里抛出异常。它作为一个普通结果返回,带 `is_error=True`。
|
||||
|
||||
!!! check
|
||||
向 `lookup_book` 查询 `"Solaris"`(目录里没有的书名),函数会抛出 `ValueError`。调用仍然正常返回:
|
||||
|
||||
```python
|
||||
result.is_error # True
|
||||
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
|
||||
result.structured_content # None
|
||||
```
|
||||
|
||||
异常消息落在了 `content` 里,**模型**可以读到它并重试。这是有意为之:工具错误是对话的一部分,不是崩溃。在相信 `structured_content` 之前,务必先看 `is_error`。
|
||||
|
||||
!!! warning
|
||||
`is_error=True` 涵盖的不只是你自己的 `raise`。请求一个服务器根本没有的工具(`call_tool("does_not_exist", {})`),什么异常都不会抛出。返回的形状相同:`is_error=True`,`content` 里是 `Unknown tool: does_not_exist`。只有当服务器回复的是 JSON-RPC **错误**而不是结果时,`Client` 方法才会抛出 `MCPError`;服务器在什么情况下产生哪一种,见 **[处理错误](../servers/handling-errors.md)**。
|
||||
|
||||
## 资源 {#resources}
|
||||
|
||||
资源动词成对出现:两种列出方式,一种读取方式。
|
||||
|
||||
```python title="client.py" hl_lines="22-31"
|
||||
--8<-- "docs_src/client/tutorial004.py"
|
||||
```
|
||||
|
||||
* `list_resources()` 返回**具体**资源,即 URI 固定的那些。这里是 `['catalog://genres']`。
|
||||
* `list_resource_templates()` 返回**参数化**的资源。这里是 `['catalog://genres/{genre}']`。它们是两个不同的列表,因为模板在填好之前是不可读的。
|
||||
* `read_resource(uri)` 接收一个普通的 `str` URI,对两者都适用:传入 `"catalog://genres/poetry"`,服务器会把它匹配到模板上。
|
||||
|
||||
`read_resource` 返回 `contents`,一个由 `TextResourceContents` 或 `BlobResourceContents` 组成的列表。思路和工具内容一样:用 `isinstance` 收窄,再读 `.text`(或 `.blob`)。
|
||||
|
||||
客户端还可以在资源变化时收到通知。在 2025 时代的连接上,这是 `subscribe_resource(uri)` / `unsubscribe_resource(uri)`——`MCPServer` 没有实现这对方法,所以在 2026-07-28 线路上(这些动词已不存在),请求会回复 `-32601`,即“Method not found”。2026 的替代方案是 `subscriptions/listen` 流,`MCPServer` **确实**提供它——那里 `server_capabilities.resources.subscribe` 为 `True`——用 `client.listen(...)` 消费它的方法见本节的 **[订阅](subscriptions.md)** 页面。
|
||||
|
||||
## 提示词 {#prompts}
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial005.py"
|
||||
```
|
||||
|
||||
`list_prompts()` 告诉你服务器提供什么,以及每个提示词需要什么:
|
||||
|
||||
```python
|
||||
prompt.name # 'recommend'
|
||||
prompt.title # 'Recommend a book'
|
||||
prompt.arguments # [PromptArgument(name='genre', required=True)]
|
||||
```
|
||||
|
||||
`get_prompt(name, arguments)` 渲染它。参数字典是 `str -> str`:提示词参数永远是字符串。结果是 `messages`,一个 `PromptMessage` 列表,每个带有 `role` 和一个 `content` 块:
|
||||
|
||||
```python
|
||||
message.role # 'user'
|
||||
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
|
||||
```
|
||||
|
||||
宿主把这些消息直接交给模型。整个功能就这些。
|
||||
|
||||
## 补全 {#completions}
|
||||
|
||||
带有补全处理函数的服务器可以在用户输入时自动补全提示词和资源模板的参数。
|
||||
|
||||
```python title="client.py" hl_lines="27-31"
|
||||
--8<-- "docs_src/client/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ref` 指明正在填写**哪个**提示词或模板:`PromptReference` 或 `ResourceTemplateReference`。
|
||||
* `argument` 是 `{"name": ..., "value": ...}`:参数名以及用户目前输入的内容。
|
||||
|
||||
答案在 `result.completion.values` 里。输入 `"p"`,服务器返回 `['poetry']`。服务器端的写法,以及处理函数如何利用**其他**已填好的参数来缩小建议范围,见 **[补全](../servers/completions.md)** 页面。
|
||||
|
||||
## 分页 {#pagination}
|
||||
|
||||
每个 `list_*` 方法都接收 `cursor=` 关键字参数,每个结果都带 `next_cursor`。`next_cursor` 为 `None` 时,说明已经拿全了。
|
||||
|
||||
```python title="client.py" hl_lines="22-30"
|
||||
--8<-- "docs_src/client/tutorial007.py"
|
||||
```
|
||||
|
||||
这个循环对任何服务器都正确。`MCPServer` 一页返回全部内容,所以 `next_cursor` 是 `None`,循环只跑一次,这也是为什么大多数代码从来不写它。真正分页的服务器,以及游标遵守的规则,见 **[分页](../advanced/pagination.md)**。
|
||||
|
||||
## 在测试中 {#in-tests}
|
||||
|
||||
没有进程、没有端口的 `Client(mcp)`,本身就是服务器的测试工具。
|
||||
|
||||
有一个构造参数专为此而设:`Client(mcp, raise_exceptions=True)`。它只对内存连接生效,**[测试](../get-started/testing.md)** 页面会解释它,并围绕它搭建完整的模式。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `Client(x)` 传入服务器对象时走内存连接,传入 URL 字符串时走 Streamable HTTP,其他情况通过传输连接。
|
||||
* `async with` 就是全部生命周期。在它内部,`server_capabilities` 和 `protocol_version` 已经填好;服务器提供时,`server_info` 和 `instructions` 也已填好。
|
||||
* `list_tools()` 给出每个工具的 `name`、`title`、`description` 和 `input_schema`。
|
||||
* `call_tool()` 返回给模型的 `content`、给代码的 `structured_content`,以及 `is_error`。抛异常的工具是一个结果,不是异常。
|
||||
* `content` 是块类型的联合;读取前先用 `isinstance` 收窄。
|
||||
* `list_resources` / `list_resource_templates` / `read_resource`、`list_prompts` / `get_prompt` 和 `complete` 补齐了全部动词。
|
||||
* 每个 `list_*` 都接收 `cursor=`;循环到 `next_cursor` 为 `None` 为止。
|
||||
|
||||
服务器可以向**客户端**请求什么,以及你如何回应,见 **[客户端回调](callbacks.md)**。
|
||||
143
i18n/zh/pages/client/oauth-clients.md
Normal file
143
i18n/zh/pages/client/oauth-clients.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
---
|
||||
translation:
|
||||
sections: [c6899d3892bd9fa0, 79372cff3cc48a88, 63878d29e87c3e73, 13175843d3588af4, e7e2b9fd516f77de, 758f06399b513c1f, a05d7278487d610b]
|
||||
tool: 1
|
||||
---
|
||||
# OAuth 客户端 {#oauth-clients}
|
||||
|
||||
有些 MCP 服务器是受保护的。不带令牌向它们发送请求,它们会回答 `401 Unauthorized`。
|
||||
|
||||
**`OAuthClientProvider`** 就是获取令牌的办法。它根本不是 MCP 对象,而是一个 `httpx2.Auth`,也就是 httpx2 中“对每个请求做点什么”的标准钩子。把它挂到 `httpx2.AsyncClient` 上,把这个客户端交给 Streamable HTTP 传输,然后就不用再管它了。
|
||||
|
||||
本页讲的是客户端一侧。让你自己的服务器要求令牌,见 **[授权](../run/authorization.md)**。
|
||||
|
||||
## 提供者 {#the-provider}
|
||||
|
||||
```python title="client.py" hl_lines="44-54"
|
||||
--8<-- "docs_src/oauth_clients/tutorial001.py"
|
||||
```
|
||||
|
||||
需要给它四样东西:
|
||||
|
||||
* `server_url`:要连接的 MCP 端点。提供者从它出发发现其余一切。
|
||||
* `client_metadata`:你会在授权服务器的“注册应用”表单里填写的内容。
|
||||
* `storage`:令牌在多次运行之间存放的地方。
|
||||
* `redirect_handler` 和 `callback_handler`:需要人参与的两个时刻。
|
||||
|
||||
文件里其他地方都没有提到 OAuth。`main()` 从头到尾看不到令牌。
|
||||
|
||||
### 客户端元数据 {#client-metadata}
|
||||
|
||||
`OAuthClientMetadata` 就是真正的 [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) 注册文档,以 Pydantic 模型的形式存在。
|
||||
|
||||
只需设置三个字段,其余由默认值补齐:`grant_types` 已经是 `["authorization_code", "refresh_token"]`,`response_types` 已经是 `["code"]`,正好是这个提供者运行的流程。
|
||||
|
||||
!!! check
|
||||
因为它是 Pydantic 模型,所以**在任何一个字节发到网络之前**就会校验。漏掉 `redirect_uris`,构造当场失败,抛出的 `ValidationError` 会点名该字段:
|
||||
|
||||
```text
|
||||
redirect_uris
|
||||
Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict]
|
||||
```
|
||||
|
||||
没有打开浏览器,也不会在授权服务器上留下注册了一半的记录。
|
||||
|
||||
### 令牌存储 {#token-storage}
|
||||
|
||||
**`TokenStorage`** 是一个带四个异步方法的 `Protocol`。不用继承任何东西;写出这些方法,任何类就都是令牌存储:
|
||||
|
||||
* `get_tokens` / `set_tokens` 保存 `OAuthToken`:访问令牌、刷新令牌、过期时间、作用域。
|
||||
* `get_client_info` / `set_client_info` 保存提供者替你注册时授权服务器颁发的 `OAuthClientInformationFull`,其中包含你的 `client_id`。
|
||||
|
||||
上面的内存版本可以工作。但进程退出时它会忘掉一切,于是下一次运行又要把整套流程重走一遍。把它持久化到文件或平台的密钥环里,下一次运行就悄无声息了。
|
||||
|
||||
!!! tip
|
||||
要存 `client_info`,而不只是令牌。提供者在第一次找不到已存的 `client_info` 时会动态注册。把它扔掉,每次运行都会生成一个全新的注册。
|
||||
|
||||
### 两个处理函数 {#the-two-handlers}
|
||||
|
||||
授权码流程恰好需要人参与一次:得有人登录并点击“允许”。
|
||||
|
||||
* **`redirect_handler`** 会以构建完整的授权 URL 为参数被 await。`client_id`、`redirect_uri`、`state` 和 PKCE challenge 都已经在里面了。你唯一要做的是让浏览器打开它。桌面应用调用 `webbrowser.open`;这个文件把它打印出来。
|
||||
* **`callback_handler`** 紧接着被 await。它一直等到用户回到你的 `redirect_uri`,然后把那次重定向的查询参数作为 `AuthorizationCodeResult` 返回。
|
||||
|
||||
真实的客户端会在重定向 URI 上运行一个小型本地 HTTP 服务器,而不是调用 `input()`。形式完全一样:被重定向,交回 `code`、`state` 和 `iss`。
|
||||
|
||||
!!! warning
|
||||
`state` 和 `iss` 要原样传递,收到什么就交回什么。提供者会把 `state` 与自己生成的值比较,把 `iss` 与发现到的颁发者比较,不匹配就拒绝。它们分别是 CSRF 防御和服务器混淆防御。
|
||||
|
||||
### 接入 `Client` {#into-the-client}
|
||||
|
||||
看一下 `main()`。提供者挂在 **httpx2 客户端**上,httpx2 客户端传入 `streamable_http_client(url, http_client=...)`,这个传输再传入 `Client`。
|
||||
|
||||
`streamable_http_client` 没有 `auth=` 关键字。所有 HTTP 层面的东西(认证、请求头、超时、代理)都属于你自己带来的 `httpx2.AsyncClient`。这种分层详见 **[客户端传输](transports.md)**。
|
||||
|
||||
## 提供者替你做了什么 {#what-the-provider-does-for-you}
|
||||
|
||||
`Client` 第一次发送请求时,服务器回答 `401`。提供者接手:
|
||||
|
||||
1. **发现。** 它读取 `WWW-Authenticate` 头,从 `/.well-known/oauth-protected-resource` 获取服务器的受保护资源元数据,得知是哪个授权服务器在保护这个资源,再去获取**那个**服务器的元数据。
|
||||
2. **注册。** 存储里什么都没有?它用你的 `OAuthClientMetadata` 动态注册,并把结果存起来。
|
||||
3. **授权。** 它生成 PKCE 对和一个 `state`,构建授权 URL,await 你的 `redirect_handler`,然后 await 你的 `callback_handler` 拿到授权码。
|
||||
4. **交换。** 它用授权码换来 `OAuthToken`,存起来,再带上 `Authorization: Bearer ...` 重放你最初的请求。
|
||||
|
||||
之后它就安静了。令牌从存储里取出,过期的访问令牌用刷新令牌刷新,只有这些都行不通时才会重新跑一遍流程。
|
||||
|
||||
这些你一行都没写。还剩两个关键字参数(`client_metadata_url` 和 `validate_resource_url`),这个文件都用不到。值得了解的是 `client_metadata_url`,下面单独有一节讲它。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
这份文档里的大多数示例都可以用内存中的 `Client(server)` 验证。这个不行:整个流程的核心就是一个 HTTP `401`,而内存中的客户端和它的服务器之间没有 HTTP。
|
||||
|
||||
仓库里附带了可实际运行的版本。`examples/servers/simple-auth/` 运行一个独立的授权服务器和一个受保护的 MCP 服务器;`examples/clients/simple-auth-client/` 是本页的客户端扩展成的一个小 CLI。它的 README 里有两条命令:启动服务器,对着它们运行客户端,就能看到这四个步骤依次走过。
|
||||
|
||||
## Client ID Metadata Documents {#client-id-metadata-documents}
|
||||
|
||||
规范的 2026-07-28 修订版弃用了动态客户端注册,改用 **Client ID Metadata Documents**(CIMD)。客户端不再向遇到的每个授权服务器 POST 一份新的注册,而是在一个稳定的 HTTPS URL 上发布一份描述自己的 JSON 文档,这个 URL **就是**它的 `client_id`。授权服务器去获取这份文档;提供者从不碰它。
|
||||
|
||||
SDK 已经支持它:构造提供者时把这个 URL 作为 `client_metadata_url=` 传入。当授权服务器的元数据声明了 `client_id_metadata_document_supported: true` 时,提供者会完全跳过 `/register` 请求:URL 作为 `client_id` 进入流程,没有 `client_secret`。当服务器没有声明它(目前大多数还没有),或者你没有传 URL 时,提供者会**悄悄地**回退到动态注册,上面的一切照常工作。已存的 `client_info` 仍然优先于这两者。
|
||||
|
||||
URL 必须是 HTTPS 且路径不能是根路径;否则在构造时就是 `ValueError`,不会发生任何网络请求。附带的 `examples/clients/simple-auth-client/` 通过环境变量 `MCP_CLIENT_METADATA_URL` 接收它。
|
||||
|
||||
## 机器对机器 {#machine-to-machine}
|
||||
|
||||
夜间任务、CI 步骤、另一个服务。没有浏览器,也没人来点“允许”。这就是 **client credentials** 授权方式:你手里已经有 `client_id` 和 `client_secret`,令牌端点就是整个流程。
|
||||
|
||||
`ClientCredentialsOAuthProvider` 是同一个 `httpx2.Auth`,只是去掉了人:
|
||||
|
||||
```python title="client.py" hl_lines="4 27-33"
|
||||
--8<-- "docs_src/oauth_clients/tutorial002.py"
|
||||
```
|
||||
|
||||
变了什么:
|
||||
|
||||
* 没有 `OAuthClientMetadata`,没有处理函数。传入 `client_id` 和 `client_secret`;提供者围绕它们构建一个最小的 `client_credentials` 注册,完全跳过动态注册。
|
||||
* `scope` 是空格分隔的字符串,即 OAuth 的线路格式。
|
||||
* 下游的一切完全相同:同样的 `TokenStorage`、同样的 `httpx2.AsyncClient(auth=...)`、同样的 `streamable_http_client`。
|
||||
|
||||
默认情况下,密钥在令牌请求里以 HTTP Basic 认证的方式传送(`client_secret_basic`)。传入 `token_endpoint_auth_method="client_secret_post"` 可以改为把它放进表单体。有些授权服务器只接受两者之一。
|
||||
|
||||
!!! tip
|
||||
从环境变量或密钥管理器读取 `client_secret`,绝不要从源码版本控制里读。
|
||||
|
||||
!!! info
|
||||
`mcp.client.auth.extensions.client_credentials` 里还有一个提供者:**`PrivateKeyJWTOAuthProvider`**,用于以 JWT 而非共享密钥进行认证的客户端(`private_key_jwt`,即密钥对和工作负载身份那一类)。它遵循同样的模式:构造一个,放到 `auth=` 上。同一个模块还附带 `SignedJWTParameters` 和 `static_assertion_provider`,两个用来构建其断言的辅助工具。
|
||||
|
||||
还有一种没有人参与的情形:客户端属于某个企业,由企业的身份提供者而不是用户来决定它可以访问哪些 MCP 服务器。那是另一种授权方式,有自己的信任模型和自己的页面,**[身份断言](identity-assertion.md)**。
|
||||
|
||||
## 出错时 {#when-it-fails}
|
||||
|
||||
OAuth 流程出错时,提供者会抛出 `mcp.client.auth` 里的 `OAuthFlowError`。它有两个子类。`OAuthRegistrationError` 表示注册没有产生一个可用的客户端:授权服务器拒绝为你注册,或者它确实注册了,但给出的凭据这个流程用不了(比如它没有实现的认证方法)。`OAuthTokenError` 表示无法获取令牌:令牌端点拒绝了,或者已存的客户端记录带有这个客户端无法应用的认证方法,这种情况在构建令牌请求时就会报告,而不会发送出去。一个 `except OAuthFlowError:` 就覆盖了发现、注册、授权和交换。
|
||||
|
||||
并非一切都是流程错误。网络仍然可能失败;那些是普通的 `httpx2` 异常,会原样透传。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `OAuthClientProvider` 是一个 `httpx2.Auth`。把它放到 `httpx2.AsyncClient` 上,再把后者传给 `streamable_http_client(url, http_client=...)`,`Client` 永远不知道发生过 OAuth。
|
||||
* 你提供四样东西:服务器 URL、一个 `OAuthClientMetadata`、一个 `TokenStorage`,以及 redirect/callback 处理函数对。
|
||||
* `TokenStorage` 是一个 `Protocol`:四个异步方法,没有基类。除了令牌,也要持久化 `client_info`。
|
||||
* 发现、注册(动态注册,或通过 **Client ID Metadata Document**)、PKCE、`state` 和 `iss` 检查,以及令牌刷新,都是提供者的事,不是你的。
|
||||
* `ClientCredentialsOAuthProvider` 是无人参与的版本:`client_id` + `client_secret`,没有处理函数,没有浏览器。
|
||||
* 每个 OAuth 失败都是 `OAuthFlowError`;`OAuthRegistrationError` 和 `OAuthTokenError` 是它的子类。
|
||||
|
||||
这次握手的另一半,让你的**服务器**要求令牌,见 **[授权](../run/authorization.md)**。
|
||||
85
i18n/zh/pages/client/session-groups.md
Normal file
85
i18n/zh/pages/client/session-groups.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
translation:
|
||||
sections: [09c857a25a9dc37a, 43bc6a76a243a50e, 0a716022a88768df, 4b7f78042bfcfff7, c112662e61b03315, 58974ba1f489a8b4, d18adbdbb835ea73]
|
||||
tool: 1
|
||||
---
|
||||
# 会话组 {#session-groups}
|
||||
|
||||
一个 `Client` 连接一个服务器。实际应用往往需要好几个(一个搜索服务器、一个数据库服务器、一个内部 API),结果要为每个服务器各管一条连接和一份工具列表。
|
||||
|
||||
**`ClientSessionGroup`** 是一个对象,它持有多条连接,并把它们公开的所有内容合并成一个统一视图。
|
||||
|
||||
## 两个服务器 {#two-servers}
|
||||
|
||||
先看两个普通的服务器。它们彼此毫无关联,所以很自然地都把自己的工具命名为 `search`:
|
||||
|
||||
```python title="library_server.py" hl_lines="7"
|
||||
--8<-- "docs_src/session_groups/tutorial001.py"
|
||||
```
|
||||
|
||||
```python title="web_server.py" hl_lines="7"
|
||||
--8<-- "docs_src/session_groups/tutorial002.py"
|
||||
```
|
||||
|
||||
## 一个组 {#one-group}
|
||||
|
||||
创建一个 `ClientSessionGroup`,对每个服务器调用一次 **`connect_to_server`**:
|
||||
|
||||
```python title="client.py" hl_lines="10-12"
|
||||
--8<-- "docs_src/session_groups/tutorial003.py"
|
||||
```
|
||||
|
||||
* `connect_to_server` 接受的是传输参数,而不是服务器对象:用 `StdioServerParameters`(来自 `mcp`)启动子进程,或用 `StreamableHttpParameters` / `SseServerParameters`(来自 `mcp.client.session_group`)连接已在某个 URL 上监听的服务器。
|
||||
* `group.tools` 是一个 `dict[str, Tool]`,包含所有已连接服务器的工具。`group.resources` 和 `group.prompts` 形式相同。
|
||||
* `group.call_tool(name, arguments)` 查找名称,找到拥有它的会话,然后转发调用。不需要指明是哪个服务器。
|
||||
|
||||
!!! check
|
||||
把 `client.py` 放在两个服务器旁边运行。第二次 `connect_to_server` 会被拒绝:
|
||||
|
||||
```text
|
||||
mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.
|
||||
```
|
||||
|
||||
这是一个 `MCPError`,在第二个服务器的任何内容注册之前就抛出了。名称必须在**整个**组内唯一,而两个不受你控制的服务器迟早会冲突。
|
||||
|
||||
## `component_name_hook` {#component_name_hook}
|
||||
|
||||
这个问题在组这一层解决,而不是在服务器上。传入一个接受 `(name, server_info)` 的函数,组会对它注册的每个名称都运行这个函数:
|
||||
|
||||
```python title="client.py" hl_lines="7-8 15"
|
||||
--8<-- "docs_src/session_groups/tutorial004.py"
|
||||
```
|
||||
|
||||
再运行一次。`print(sorted(group.tools))` 现在两个都显示了:
|
||||
|
||||
```text
|
||||
['Library.search', 'Web.search']
|
||||
```
|
||||
|
||||
* **键**由你决定。`by_server` 用 `server_info.name` 构造它,也就是每个 `MCPServer(...)` 构造时传入的名称。
|
||||
* 里面的 `Tool` 原封不动:`group.tools["Web.search"].name` 仍然是 `"search"`,这也是 `call_tool` 发到线路上的名称。前缀永远不会离开你的进程。
|
||||
* 不只是工具。library 的 `hours` 资源注册为 `Library.hours`。
|
||||
|
||||
!!! tip
|
||||
这个 hook 对**每个**服务器的**每个**名称都会运行,而不仅限于冲突的名称:没有"仅在冲突时加前缀"的模式。选定一种方案,让它处处生效。
|
||||
|
||||
## 添加和移除服务器 {#adding-and-removing-servers}
|
||||
|
||||
`connect_to_server` 返回它打开的 `ClientSession`。如果以后想移除这个服务器,就保留它:`await group.disconnect_from_server(session)` 会把它的工具、资源和提示词从组中移除。
|
||||
|
||||
如果手上已经有一个已连接的 `ClientSession`(`Client.session` 就是一个),把它交给 `await group.connect_with_session(server_info, session)`,而不用打开新的传输。聚合方式相同。组永远不会关闭不是它自己打开的会话。`server_info` 为组件前缀提供服务器名称;在 2026 年代的连接上,`client.server_info` 可能是 `None`(身份是可选的),这种情况下传入你自己的 `Implementation(name=..., version=...)`。
|
||||
|
||||
## 经典握手 {#the-classic-handshake}
|
||||
|
||||
`ClientSessionGroup` 构建在 `ClientSession` 之上,而不是 `Client`。每次 `connect_to_server` 都运行经典的 `initialize` 握手。它从不发送 **[协议版本](../protocol-versions.md)** 中描述的 `server/discover` 探测。每个 MCP 服务器都理解这种握手,所以这不会损失任何兼容性;它只意味着,面对一个本可以做得更好的服务器,组走的是较旧、较慢的路径。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `ClientSessionGroup` 持有多条服务器连接,并把它们的工具、资源和提示词各自合并成一个 `dict`。
|
||||
* 每个服务器调用一次 `connect_to_server(params)`。它接受传输参数,从不接受 `Client` 所接受的服务器对象或 URL。
|
||||
* `group.call_tool(name, arguments)` 替你路由到拥有该工具的服务器。
|
||||
* 名称必须在整个组内唯一;两个都有 `search` 工具的服务器无法直接共存。
|
||||
* `component_name_hook=` 改写每个注册的名称。改变的是 dict 的键,线路上的名称不变。
|
||||
* `connect_with_session` 添加一个你已持有的会话;`disconnect_from_server` 移除一个。
|
||||
|
||||
组所用的握手(以及 `Client` 更倾向的那种更快的握手)详见 **[协议版本](../protocol-versions.md)**。
|
||||
88
i18n/zh/pages/client/subscriptions.md
Normal file
88
i18n/zh/pages/client/subscriptions.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
translation:
|
||||
sections: [8f9558e57f29eee1, a88c587739e0465c, 46ebfd5b325ed041, 4d10b00b57ce4bd9, 2cdb0edd1f59b3e2]
|
||||
tool: 1
|
||||
---
|
||||
# 订阅 {#subscriptions}
|
||||
|
||||
服务器的目录不是固定的。工具会在运行时出现,资源 URI 背后的内容也会变化。客户端通过 `client.listen(...)` 获知这些变化:一个 `subscriptions/listen` 请求,它的响应**就是**流本身。流保持打开,承载客户端要求的那些变更通知。
|
||||
|
||||
本页讲的是客户端这一端:打开流、在主流程旁边监听它,以及处理它的各种结束方式。发布变更、过滤以及提供该方法的服务,是服务器那一侧的内容,见“在处理函数内部”下的 **[订阅](../handlers/subscriptions.md)**。这里的示例连接的是在那里构建的 sprint-board 服务器。
|
||||
|
||||
## 监听流 {#watching-the-stream}
|
||||
|
||||
一个订阅就是一个上下文管理器。进入它会发送请求,把你传入的关键字参数作为订阅过滤器,并等待服务器的确认,所以代码块开始执行时流已经是活动的。
|
||||
|
||||
```python title="client.py" hl_lines="15 18 28"
|
||||
--8<-- "docs_src/subscriptions/tutorial003.py"
|
||||
```
|
||||
|
||||
迭代会产出四种带类型的事件:`ToolsListChanged`、`PromptsListChanged`、`ResourcesListChanged` 和 `ResourceUpdated(uri=...)`。
|
||||
|
||||
事件只说明**什么**变了,从不说明**怎么**变的。这就是 `follow_board` 调用 `read_resource` 和 `list_tools` 的原因:事件是重新获取的信号。读取 `event.uri`,不要假定是哪个资源变了:一个过滤器可以列出多个 URI,服务器也可能报告其中某个 URI 的子资源发生了变化。
|
||||
|
||||
等待消费的重复事件会合并成一个,重新获取拿到的依然是当前状态。只有完全相同的事件才会合并:两个针对不同 URI 的 `ResourceUpdated` 是两个事件。
|
||||
|
||||
这个句柄还有两个属性:
|
||||
|
||||
* `sub.honored` 是服务器确认的过滤器:一个 `SubscriptionFilter`,带有你传入的字段,以属性方式读取(`sub.honored.prompts_list_changed`)。`MCPServer` 会满足你要求的每一种类型,所以它会把你的请求原样回显。支持类型较少的服务器确认的也更少,而一个被确认的类型也可能永远不会触发。服务器还可能拒绝整个请求而不是确认它(见服务器页面上的[决定谁可以监听](../handlers/subscriptions.md#deciding-who-may-watch)),这会以请求错误的形式出现。
|
||||
* `sub.subscription_id` 是 listen 请求的 id,也就是印在这个流每一帧上的那个 id。可以同时打开多个订阅,各自按自己的 id 解复用。
|
||||
|
||||
## 不阻塞地监听 {#watching-without-blocking}
|
||||
|
||||
`follow_board` 会一直运行到服务器关闭流为止,而这可能永远不会发生,所以单独使用时它会占据你的整个程序。真实的客户端希望监听任务运行在主流程**旁边**:智能体调用工具的同时,监听任务让缓存或 UI 保持最新。
|
||||
|
||||
先打开订阅,再启动监听任务,然后继续做自己的事。
|
||||
|
||||
=== "asyncio"
|
||||
|
||||
```python title="app.py" hl_lines="18 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
|
||||
```
|
||||
|
||||
=== "trio"
|
||||
|
||||
```python title="app.py" hl_lines="18 21"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
|
||||
```
|
||||
|
||||
=== "anyio"
|
||||
|
||||
```python title="app.py" hl_lines="18 21"
|
||||
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
|
||||
```
|
||||
|
||||
!!! note
|
||||
`app.py` 从第一个示例导入 `BOARD` 和 `read_board`,本仓库把那个示例保存为 `tutorial003.py`。如果你把渲染出的文件并排保存为 `client.py` 和 `app.py`,就改写成 `from client import BOARD, read_board`。更下面的 `watch.py` 示例也以同样的方式导入 `read_board`。
|
||||
|
||||
顺序是关键。没有任何内容会重放,所以在你的流存在之前发布的事件会被错过。进入 `client.listen(...)` 会等待确认,所以从那一刻起的每一个变更都会到达你的监听任务,而你在代码块内获取的快照不可能漏掉任何一个。
|
||||
|
||||
在打开的流旁边,请求可以自由运行,无论来自监听任务还是其他任何任务,都在同一个客户端上。因为**重复**的未消费事件会合并,一个繁忙的主流程可能只产生一次重新获取而不是三次。不同的事件不会合并:一个列出许多 URI 的过滤器会为每个 URI 排队一个待处理事件。
|
||||
|
||||
要停止监听,离开代码块即可:没有 `unsubscribe` 调用。取消拥有该代码块的任务会替你做到这一点,SDK 会按传输期望的方式取消 listen 请求:在 Streamable HTTP 上,就是关闭该请求的流。一个随应用整个生命周期运行的监听任务永远不会自行返回,所以在关闭时取消它,或者取消它所在任务组的作用域。
|
||||
|
||||
## 流会结束 {#streams-end}
|
||||
|
||||
流以两种方式之一结束,两者都是普通的控制流。服务器优雅关闭会结束 `async for`;突然断开会抛出 `SubscriptionLost`。
|
||||
|
||||
两者的区别在于诊断意义,而不在于接下来该做什么:流没了,没有任何内容会重放,仍然关心的监听任务就重新 listen 并重新获取。
|
||||
|
||||
```python title="watch.py" hl_lines="16 20"
|
||||
--8<-- "docs_src/subscriptions/tutorial005.py"
|
||||
```
|
||||
|
||||
服务器会出于自己的原因优雅地关闭流,包括甩掉积压过多的订阅者,所以干净的结束并不是停止监听的信号。重新 listen 之前先退避。
|
||||
|
||||
`SubscriptionLost` 也有一个本地原因。客户端最多保存 1024 个未消费事件,落后到这个程度的消费者会失去订阅,而不是无限制地增长。让 `async for` 的循环体保持简短,把耗时的工作放到别处。
|
||||
|
||||
`keep_following` 只捕获 `SubscriptionLost`。进入 `listen()` 还可能抛出 `MCPError`(连接失败,或服务器不提供该方法)、`TimeoutError`(没有收到确认)和 `ListenNotSupportedError`(2026 之前的连接)。决定你的监听任务应该对其中哪些重试:最后一个永远不会自愈。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 进入 `async with client.listen(...)`;进入时会等待确认,所以之后发布的任何内容都不会漏掉。
|
||||
* 用 `async for event in sub` 迭代。事件是重新获取的信号,从来不是载荷。
|
||||
* 先打开订阅,再把监听任务作为任务运行,工具调用在旁边照常进行。
|
||||
* 干净的结束会停止循环;断开会抛出 `SubscriptionLost`。无论哪种:重新 listen、重新获取,先退避。
|
||||
* 离开代码块就是取消订阅。
|
||||
|
||||
发布这些事件、收窄过滤器以及扩展到单进程之外,是服务器那一侧的内容:**[订阅](../handlers/subscriptions.md)**。同样这些事件也能让客户端缓存保持可信,下一页是 **[缓存](caching.md)**。
|
||||
117
i18n/zh/pages/client/transports.md
Normal file
117
i18n/zh/pages/client/transports.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
---
|
||||
translation:
|
||||
sections: [9cac816674181eb0, 0700f337babcd4dd, 2bde0dd58cdf00f5, ff7401df479af877, 3d0832f39b0d7059, d4bf7e4479637768, 05e20c0a798860e7]
|
||||
tool: 1
|
||||
---
|
||||
# 客户端传输 {#client-transports}
|
||||
|
||||
每个 `Client` 都通过一种**传输**与它的服务器通信:真正承载消息的那一层。
|
||||
|
||||
你从来不需要单独配置它。`Client` 只接受一个位置参数,并根据它的类型推断出传输方式。
|
||||
|
||||
每种传输的**服务器**一侧(`mcp.run()` 做什么、你部署什么)见 **[运行你的服务器](../run/index.md)**。
|
||||
|
||||
## 内存中 {#in-memory}
|
||||
|
||||
传入服务器对象本身:
|
||||
|
||||
```python title="client.py" hl_lines="14"
|
||||
--8<-- "docs_src/client_transports/tutorial001.py"
|
||||
```
|
||||
|
||||
没有子进程,没有端口,线路上没有任何字节。客户端和服务器是同一个进程里的两个对象,而调用仍然走真实的协议层:`search_books` 的列出、校验和调用,和走 HTTP 时完全一样。
|
||||
|
||||
这让它同时具有两种用途:
|
||||
|
||||
* **测试支架。** 本文档中的每个示例都是这样跑通的,**[测试](../get-started/testing.md)** 页面围绕它构建了整套模式。
|
||||
* **嵌入 API。** 自己构造服务器的应用不需要经过网络就能调用它的工具。
|
||||
|
||||
## Streamable HTTP {#streamable-http}
|
||||
|
||||
传入一个 URL 字符串,得到的就是 **Streamable HTTP**,也就是部署时用的传输方式:
|
||||
|
||||
```python title="client.py" hl_lines="5"
|
||||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
```
|
||||
|
||||
这就是完整的生产环境客户端。`Client` 替你把 URL 包进 `streamable_http_client(...)`,底层是一个按 MCP 的需要配置好的 `httpx2.AsyncClient`:`follow_redirects=True`,connect/write/pool 超时 30 秒,读超时 300 秒,因为服务器可能会一直保持响应流打开。
|
||||
|
||||
!!! check
|
||||
构造出来的 `Client` **并未**连接。构造只是选定传输方式;打开它的是 `async with`。在进入之前就去取连接,SDK 会明确告诉你:
|
||||
|
||||
```text
|
||||
RuntimeError: Client must be used within an async context manager
|
||||
```
|
||||
|
||||
写下 `Client("http://...")` 时,没有解析任何东西,没有获取任何东西,也没有启动任何进程。这一行没有任何开销。
|
||||
|
||||
### 自带 `httpx2.AsyncClient` {#bring-your-own-httpx2asyncclient}
|
||||
|
||||
一旦需要 `Authorization` 头、cookie、代理、mTLS 或不同的超时,就自己构建 `httpx2.AsyncClient`,再把它交给 `streamable_http_client`:
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||
```
|
||||
|
||||
注意两点:
|
||||
|
||||
* `httpx2.AsyncClient` 归你所有,所以由**你**进入和退出它。SDK 从不关闭不是它自己创建的客户端。
|
||||
* `streamable_http_client(url, http_client=...)` 返回一个传输,`Client(transport)` 像接受其他任何东西一样接受它。
|
||||
|
||||
关于 TLS 的一点说明:`httpx2` 依据操作系统的信任库(通过
|
||||
[`truststore`](https://pypi.org/project/truststore/))校验证书,而不是自带的 CA 列表。在没有可用系统 CA 库的环境(某些精简容器)中,设置标准的 `SSL_CERT_FILE`/`SSL_CERT_DIR`
|
||||
环境变量,或者给你的 `httpx2.AsyncClient` 显式传入 `verify=ssl_context`(背景见
|
||||
[`httpx` 和 `httpx-sse` 被 `httpx2` 取代](../migration.md#httpx-and-httpx-sse-replaced-by-httpx2))。
|
||||
|
||||
!!! warning
|
||||
`streamable_http_client` 过去可以直接接受 `headers=` 和 `timeout=`。现在不行了:它只有 `url`、`http_client` 和 `terminate_on_close` 三个参数。习惯性地去用 `headers=`,会得到:
|
||||
|
||||
```text
|
||||
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
|
||||
```
|
||||
|
||||
所有 HTTP 层面的东西现在都放在你传入的那一个 `httpx2.AsyncClient` 上。
|
||||
|
||||
!!! info
|
||||
`httpx2` 保留了熟悉的 `httpx` API,所以只要会 `httpx`,就已经知道在这里怎么做认证、代理、事件钩子、重试和连接限制。SDK 既不在上面加东西,也不拿走什么。OAuth 也是在这里接入的:`httpx2.AsyncClient(auth=OAuthClientProvider(...))`。整个流程见 **[OAuth 客户端](oauth-clients.md)**。
|
||||
|
||||
## stdio {#stdio}
|
||||
|
||||
**stdio** 服务器是一个子进程。客户端启动它,向它的 stdin 写 JSON-RPC,从它的 stdout 读 JSON-RPC。桌面宿主就是这样在你的机器上运行服务器的:宿主**就是**这段代码加上一个 UI,而 **[连接到真实宿主](../get-started/real-host.md)** 是从宿主一侧、以配置文件的形式看到的同一种关系。
|
||||
|
||||
用 `StdioServerParameters` 描述进程,用 `stdio_client` 把它变成传输,再把**它**交给 `Client`:
|
||||
|
||||
```python title="client.py" hl_lines="4-8 12"
|
||||
--8<-- "docs_src/client_transports/tutorial004.py"
|
||||
```
|
||||
|
||||
`Client` 不接受单独的参数对象。`StdioServerParameters` 是配置;`stdio_client(server)` 才是知道如何据此启动进程的传输。一定要包一层。
|
||||
|
||||
离开 `async with` 块也会关停子进程:关闭 stdin,等待,如果它迟迟不退出就杀掉。你从来不需要自己清理。
|
||||
|
||||
!!! warning
|
||||
子进程**不会**继承你的环境。它只拿到一个最小的允许列表(POSIX 上是 `HOME`、`LOGNAME`、`PATH`、`SHELL`、`TERM` 和 `USER`),这样敏感信息就不会泄漏进一个可能不是你写的进程。
|
||||
|
||||
需要 API key 的服务器在那里找不到它。用 `env=` 显式传入;这些变量会合并到允许列表之上。上面的 `BOOKSHOP_API_KEY` 做的就是这件事。
|
||||
|
||||
## SSE {#sse}
|
||||
|
||||
`sse_client(url)` 来自 `mcp.client.sse`,是被 Streamable HTTP 取代的那个 HTTP 传输。用同样的方式包一层,`Client(sse_client("http://localhost:8000/sse"))`,就能和仍在使用它的服务器通信;不要在它之上构建任何新东西。
|
||||
|
||||
## `Transport` 协议 {#the-transport-protocol}
|
||||
|
||||
对 `Client` 来说,上面这些都是同一种东西。
|
||||
|
||||
**传输**是任何能产出一对 `(read, write)` 消息流的异步上下文管理器:正式地说,就是 `mcp.client` 中的 `Transport` 协议。`Client` 按类型解析它的参数:服务器对象在进程内连接,`str` 变成 `streamable_http_client(url)`,其他任何东西都直接作为传输进入。正是最后这条规则让 `stdio_client(...)`、`streamable_http_client(...)` 和 `sse_client(...)` 都能放进同一个位置,也让你可以自己写一个。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `Client(mcp)`(服务器对象)在内存中连接。用于测试和嵌入。
|
||||
* `Client("http://.../mcp")`(URL)通过 Streamable HTTP 连接,即生产环境的传输方式。
|
||||
* 请求头、认证、代理和超时应放在 `httpx2.AsyncClient` 上,再传给 `streamable_http_client(url, http_client=...)`。没有 `headers=` 关键字参数。
|
||||
* stdio 是 `Client(stdio_client(StdioServerParameters(...)))`,绝不是单独的参数对象。
|
||||
* 子进程拿到的是允许列表里的环境,不是你的环境;`env=` 往里添加。
|
||||
* 传输就是任何可以 `async with x as (read, write)` 的东西。凡不是服务器对象或 URL 的参数,`Client` 都直接交给这个协议。
|
||||
* 构造 `Client` 选定传输方式。`async with` 打开它。
|
||||
|
||||
传输打开之后,两边必须就协议版本达成一致。通常根本不用考虑它;需要考虑的时候,去看 **[协议版本](../protocol-versions.md)**。
|
||||
Loading…
Reference in a new issue