docs: publish translated docs in twelve languages and the tool that maintains them (#3280)
This commit is contained in:
parent
14aa889992
commit
52ad0a8876
50 changed files with 89737 additions and 54 deletions
121
i18n/zh/pages/advanced/apps.md
Normal file
121
i18n/zh/pages/advanced/apps.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
---
|
||||
translation:
|
||||
sections: [0355618e5f4d5fe4, 1821eaf50f2d0b64, 82e0b28ebd3abf5a, 8ac39614c094f2d0, dab6ff945501ab2a, bd5565c3b2d4f959, 96819ce3d63a0487]
|
||||
tool: 1
|
||||
---
|
||||
# MCP Apps {#mcp-apps}
|
||||
|
||||
**MCP App** 是带界面的工具:除了返回数据,这个工具还指向一个 HTML 文档,由宿主渲染成可交互的界面。
|
||||
|
||||
两个部分,永远是两个部分:
|
||||
|
||||
1. **一个工具**,负责干活并返回数据,和其他工具一样。
|
||||
2. **一个 `ui://` 资源**,包含宿主为它展示的 HTML。
|
||||
|
||||
工具通过 `_meta.ui.resourceUri` 引用这个资源。宿主用 `resources/read` 获取它,在**沙箱化的 iframe** 里渲染,再通过 `postMessage` 把工具的结果推送进 iframe。你的服务器从不收发任何 `ui/*` 消息:那些流量只在宿主和 iframe 之间往来。你提供一个工具和一份 HTML 文档,展示的事由宿主包办。
|
||||
|
||||
SDK 把它作为内置的 `Apps` 扩展(`io.modelcontextprotocol/ui`)提供。如果还不熟悉[扩展](extensions.md),先浏览一下那一页。一分钟就够,然后回来。
|
||||
|
||||
## 一个带界面的时钟 {#a-clock-with-a-face}
|
||||
|
||||
```python title="server.py" hl_lines="19 22 30 32"
|
||||
--8<-- "docs_src/apps/tutorial001.py"
|
||||
```
|
||||
|
||||
四步:
|
||||
|
||||
* `Apps()`:一个实例容纳所有绑定 UI 的工具及其资源。
|
||||
* `@apps.tool(resource_uri="ui://clock/app.html")`:一个普通工具,外加 `_meta.ui.resourceUri` 标记。`@mcp.tool()` 接受的所有参数(name、title、description……)都会原样传递。
|
||||
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`:与之对应的资源,以 `text/html;profile=mcp-app` 提供。正是这个 MIME 类型告诉宿主“这是一个 app,渲染它”。
|
||||
* `MCPServer("clock", extensions=[apps])`:选择启用。服务器现在会在 `capabilities.extensions` 下声明 `io.modelcontextprotocol/ui`。
|
||||
|
||||
HTML 本身监听宿主的 `postMessage` 并显示结果。真正的应用请在 HTML 里使用官方的 [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) 浏览器 SDK。它提供 `ontoolresult`、`callServerTool`、`getHostContext` 和 `onhostcontextchanged`,不用再处理原始的 message 事件。
|
||||
|
||||
## 优雅降级 {#graceful-degradation}
|
||||
|
||||
不是每个客户端都能渲染 app。这对你意味着什么,规范说得很直白:
|
||||
|
||||
> 即使有 UI 可用,工具也**必须**返回有意义的 `content` 数组。
|
||||
|
||||
模型读的是 `content`;iframe 是给人看的。支持 UI 的宿主照样会把文本结果交给模型,而纯文本客户端**只**拿到那部分。所以标准模式是一个工具、两种回答。再看一遍 `get_time`:
|
||||
|
||||
```python title="server.py" hl_lines="23-27"
|
||||
--8<-- "docs_src/apps/tutorial001.py"
|
||||
```
|
||||
|
||||
只有当客户端声明了 `io.modelcontextprotocol/ui` 扩展,**并且**在其 `mimeTypes` 设置里列出了 `text/html;profile=mcp-app` 时,`client_supports_apps(ctx)` 才为 `True`。这个字段是必填的,省略它的客户端不算数。同一文件里的 `main()` 声明的正是这些:协商中客户端的那一半,于是富结果就返回了。
|
||||
|
||||
!!! warning
|
||||
绝不要把 `"[Rendered UI]"` 这样的占位符当作唯一的内容返回。如果回退文本没用,这个工具对所有纯文本客户端乃至模型本身就都没用。把那句话写出来。
|
||||
|
||||
## 给 iframe 上锁 {#locking-the-iframe-down}
|
||||
|
||||
安全相关的元数据放在资源一侧:iframe 可以加载什么、想要哪些浏览器权限、希望被怎样嵌入:
|
||||
|
||||
```python title="server.py" hl_lines="9 19-22"
|
||||
--8<-- "docs_src/apps/tutorial002.py"
|
||||
```
|
||||
|
||||
`csp` 和 `permissions` 是**向宿主提出的请求**,不是服务器的行为。宿主据此构建 iframe 的 Content-Security-Policy 和 Permissions-Policy,也可以拒绝。在 JS 里做特性检测,不要假定已经获准。
|
||||
|
||||
`ResourceCsp` 逐字段说明(Python 名、线路上的键、宿主拿它做什么):
|
||||
|
||||
| Python | 线路(`_meta.ui.csp`) | 控制 |
|
||||
|---|---|---|
|
||||
| `connect_domains` | `connectDomains` | `connect-src`:`fetch`/XHR 可以访问哪里 |
|
||||
| `resource_domains` | `resourceDomains` | `img-src`、`style-src`……:静态资源 |
|
||||
| `frame_domains` | `frameDomains` | `frame-src`:嵌套的 iframe |
|
||||
| `base_uri_domains` | `baseUriDomains` | `base-uri`:`<base>` 可以指向哪里 |
|
||||
|
||||
`ResourcePermissions`:每个字段为 iframe 请求一项浏览器权限。
|
||||
|
||||
| Python | 线路(`_meta.ui.permissions`) |
|
||||
|---|---|
|
||||
| `camera` | `camera` |
|
||||
| `microphone` | `microphone` |
|
||||
| `geolocation` | `geolocation` |
|
||||
| `clipboard_write` | `clipboardWrite` |
|
||||
|
||||
!!! note
|
||||
CSP 和权限放在**资源**上,绝不放在工具上。规范的工具元数据里没有它们的位置,放在那里宿主也会忽略。SDK 让这个错误根本无从表达:`@apps.tool()` 压根没有 `csp` 参数。
|
||||
|
||||
### 可见性 {#visibility}
|
||||
|
||||
工具上的 `visibility=["app"]` 表示“这是给 iframe 用的,不是给模型用的”:
|
||||
|
||||
* `"model"`:模型可以调用它。
|
||||
* `"app"`:iframe 可以调用它(通过 `callServerTool`)。
|
||||
* 省略:两者都可以,这也是默认值。
|
||||
|
||||
过滤是**宿主**的事。服务器在 `tools/list` 里照常列出仅限 app 的工具;宿主负责对模型隐藏它们。不要在服务器端过滤。
|
||||
|
||||
## SDK 强制执行的规则 {#the-rules-the-sdk-enforces}
|
||||
|
||||
这些都会在启动时失败,而不是在生产环境里:
|
||||
|
||||
* 不是 `ui://...` 的 `resource_uri` 或资源 URI,在装饰/注册时抛出 `ValueError`。
|
||||
* 绑定到某个 URI 却**没有对应的已注册资源**的工具,在 `MCPServer(extensions=[apps])` 消费这个扩展时抛出 `ValueError`。一个声明了 HTML、却在 `resources/read` 上 404 的工具属于配置错误,所以直接拒绝构造。
|
||||
* 在 `@apps.tool()` 上传入 `meta={"ui": ...}` 会抛出 `ValueError`。`_meta["ui"]` 归装饰器管;用 `resource_uri=` 和 `visibility=` 来表达。其他 `meta=` 键可以正常一并合并。
|
||||
|
||||
TypeScript 的 ext-apps SDK 和 FastMCP 目前都不检查这些;我们宁愿你先于宿主发现问题。
|
||||
|
||||
## 内联 HTML 之外 {#beyond-inline-html}
|
||||
|
||||
`add_html_resource` 覆盖常见情况:一段 HTML 字符串。其他情况,比如磁盘上的 HTML 或生成的内容,自己构建资源再交给它:
|
||||
|
||||
```python title="server.py" hl_lines="12 18"
|
||||
--8<-- "docs_src/apps/tutorial003.py"
|
||||
```
|
||||
|
||||
资源没有显式设置 MIME 类型时,`add_resource` 会填上 `text/html;profile=mcp-app`;显式设置了不匹配的类型则会拒绝:用其他任何 MIME 类型的 `ui://` 资源,没有宿主会渲染。
|
||||
|
||||
!!! tip
|
||||
目标宿主是 GA 之前的版本,还在读取已弃用的扁平键 `_meta["ui/resourceUri"]`?自己合并进去:`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`。嵌套的 `ui` 对象才是规范规定的形态;扁平键正在退出。
|
||||
|
||||
## 运行看看 {#see-it-run}
|
||||
|
||||
`examples/stories/` 里的 `apps` story 就是本页的可运行版本,由一对程序组成:一个带有绑定 UI 的时钟工具的服务器,和一个协商 Apps、读取工具的 `_meta.ui.resourceUri`、获取 HTML 并调用工具的客户端。
|
||||
|
||||
```bash
|
||||
uv run python -m stories.apps.client
|
||||
```
|
||||
172
i18n/zh/pages/advanced/extensions.md
Normal file
172
i18n/zh/pages/advanced/extensions.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
---
|
||||
translation:
|
||||
sections: [05891e7cc1938a13, b3c01a6af28c51ee, 7ffc91f5e38bdfe0, 717d3f235a8333a7, f471a13b2fe5d737, ed6af2df4b656dff]
|
||||
tool: 1
|
||||
---
|
||||
# 扩展 {#extensions}
|
||||
|
||||
**扩展**是一组归在同一个标识符之下、需要主动启用的 MCP 行为。
|
||||
|
||||
在服务器上,它可以贡献工具、资源和新的请求方法,还可以包裹 `tools/call`。在客户端上,它可以认领额外的 `tools/call` 结果形态,并观察厂商通知。两端各自在自己的 `capabilities.extensions` 下声明,对没有要求它的人来说一切照旧。这就是约定([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)),它只有一条铁律:**扩展默认关闭**。
|
||||
|
||||
## 使用扩展 {#using-an-extension}
|
||||
|
||||
在构造时传入实例:
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/extensions/tutorial001.py"
|
||||
```
|
||||
|
||||
完成。服务器现在会在 `capabilities.extensions` 下声明 `io.modelcontextprotocol/ui`,并提供该扩展贡献的一切。
|
||||
|
||||
`Apps` 是内置的参考扩展,它有自己的页面:**[MCP Apps](apps.md)**。
|
||||
|
||||
!!! note
|
||||
扩展在构造时就固定下来。没有可以事后调用的 `add_extension`:客户端连着的时候,服务器的能力映射不应该变。
|
||||
|
||||
能力映射随 `server/discover` 传递,这是 **2026-07-28** 的路径。旧版 `initialize` 握手没有地方放它,所以旧版客户端根本看不到这个扩展。设计时要考虑到这一点:扩展是对服务器的**增强**,绝不能成为服务器唯一可用的途径。
|
||||
|
||||
## 编写自己的扩展 {#writing-your-own}
|
||||
|
||||
继承 `Extension`,只重写需要的部分。每个方法都有默认实现。
|
||||
|
||||
### 标识符 {#the-identifier}
|
||||
|
||||
```python
|
||||
--8<-- "docs_src/extensions/tutorial002.py"
|
||||
```
|
||||
|
||||
标识符是一个 `vendor-prefix/name` 字符串,遵循规范中 `_meta` 键的语法:用点分隔的标签(每个以字母开头,以字母或数字结尾),一个斜杠,然后是名称。它在**类定义时**就会被校验,所以拼写错误不会等到服务器启动才暴露:
|
||||
|
||||
```text
|
||||
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
|
||||
(reverse-DNS prefix required), got 'stamps'
|
||||
```
|
||||
|
||||
用你控制的域名作前缀。`io.modelcontextprotocol/*` 留给 MCP 项目自己规范的扩展。
|
||||
|
||||
### 贡献工具 {#contributing-tools}
|
||||
|
||||
最小的有用扩展就是一个工具加一份设置映射:
|
||||
|
||||
```python title="server.py" hl_lines="17 19-20 22-23 26"
|
||||
--8<-- "docs_src/extensions/tutorial003.py"
|
||||
```
|
||||
|
||||
* `tools()` 返回 `ToolBinding`。服务器注册每一个的方式与你自己调用 `mcp.add_tool(...)` 完全一样:同样的模式生成,同样的 `Context` 注入,一切都一样。
|
||||
* `settings()` 是在 `capabilities.extensions["com.example/stamps"]` 处声明的值。返回 `{}`(默认值)表示声明该扩展但不带任何设置。
|
||||
* 扩展永远拿不到服务器。它以数据的形式声明贡献,由 `MCPServer` 消费。没有可供修改的 `self.server`。
|
||||
|
||||
`main()` 就是证明:一个直接对着 `mcp` 的内存客户端:
|
||||
|
||||
```python title="server.py" hl_lines="29-34"
|
||||
--8<-- "docs_src/extensions/tutorial003.py"
|
||||
```
|
||||
|
||||
### 提供自己的方法 {#serving-your-own-methods}
|
||||
|
||||
扩展可以注册**新的请求方法**:它自己的动词,与规范定义的方法并列提供:
|
||||
|
||||
```python title="server.py" hl_lines="16-22 31 40-48"
|
||||
--8<-- "docs_src/extensions/tutorial004.py"
|
||||
```
|
||||
|
||||
* `SearchParams` 继承 `RequestParams`,因此 2026 的 `_meta` 信封能统一解析,处理函数拿到的是校验过的参数,而不是原始 dict。对客户端能控制的东西加上限制:`Field(ge=1, le=100)` 会在你的代码为它分配任何东西之前就拒绝离谱的 `limit`。
|
||||
* `require_client_extension(ctx, EXTENSION_ID)` 是门槛:没有声明该扩展的客户端会收到 `-32021`(缺少必需的客户端能力)错误,并附带规范要求的机器可读 `requiredCapabilities` 载荷。
|
||||
* `protocol_versions=frozenset({"2026-07-28"})` 把该方法固定在一个线路版本上。在其他任何版本下,客户端得到 `METHOD_NOT_FOUND`,就跟这个方法在那里不存在一样。对那个客户端而言,它确实不存在。
|
||||
|
||||
方法是**严格增量**的。SDK 在构造时而不是运行时强制这一点:
|
||||
|
||||
* 为规范定义的方法(`tools/list`、`completion/complete`……)创建 `MethodBinding`,会在构造该绑定时抛出 `ValueError`。核心动词属于服务器。
|
||||
* 两个扩展绑定同一个方法,第二个注册时抛出异常。“后写者胜”正是插件互相破坏的方式,我们不这么做。
|
||||
* 空的 `protocol_versions` 集合同样抛出异常:一个永远无法提供的方法是 bug,不是配置。
|
||||
|
||||
### 客户端一侧 {#the-client-side}
|
||||
|
||||
同一个文件的 `main()` 就是客户端的全部内容,两半都在:
|
||||
|
||||
```python title="server.py" hl_lines="54-58"
|
||||
--8<-- "docs_src/extensions/tutorial004.py"
|
||||
```
|
||||
|
||||
* `Client(..., extensions=[advertise(EXTENSION_ID)])` 声明该扩展。这些声明会变成 `ClientCapabilities.extensions`:在 2026-07-28 连接上,该映射随每个请求的 `_meta` 信封传递,所以服务器在**每个**请求上都能看到它;在旧版连接上,它随 `initialize` 握手传递。服务器代码不用关心是哪一种:`require_client_extension(ctx, ...)` 和 `ctx.session.check_client_capability(...)` 在两条路径上都会读取正确的来源。
|
||||
* 厂商方法要往下一层,用 `client.session.send_request(...)`;`Client` 只为规范动词提供一等方法。`send_request` 接受任何 `Request` 子类,所以厂商请求原样传入即可。
|
||||
|
||||
### 拦截 `tools/call` {#intercepting-toolscall}
|
||||
|
||||
唯一的拦截型钩子。重写 `intercept_tool_call` 来观察、短路或否决一次工具调用:
|
||||
|
||||
```python title="server.py" hl_lines="17-24"
|
||||
--8<-- "docs_src/extensions/tutorial005.py"
|
||||
```
|
||||
|
||||
* `params` 是校验过的 `CallToolRequestParams`:不用碰原始 JSON 就能拿到 `params.name` 和 `params.arguments`。决定运行哪个工具调用的也是它:通过 `call_next` 传入一个改写过的 context,改变的是处理函数在 `ctx` 上看到的内容,而不是工具调用本身。线路层面的请求改写属于[中间件](middleware.md)的事。
|
||||
* `call_next(ctx)` 运行链上剩余的部分并返回处理函数的结果。原样返回它(观察)、返回别的东西(替换),或者抛出 `MCPError`(拒绝)。无论返回什么,都会像任何处理函数结果一样被序列化,包括 2026 时代的 `serverInfo` 身份标记,所以短路的拦截器永远不会产生匿名或不符合模式的响应。
|
||||
* 有多个扩展时,拦截器按注册顺序嵌套:`extensions=[...]` 里的第一个扩展在最外层。
|
||||
* 默认实现是直通。如果服务器的扩展都没有重写这个钩子,裸 `tools/call` 处理函数就保持原封不动。不用的东西不用付出代价。
|
||||
|
||||
这个钩子只包裹 `tools/call`,别无其他。涉及每条消息的事情,用[中间件](middleware.md)。它就是干这个的。
|
||||
|
||||
## 使用客户端扩展 {#using-a-client-extension}
|
||||
|
||||
**客户端扩展**是从消费一侧看的同一份约定:一组归在同一个标识符之下的客户端行为。把实例传给 `Client(extensions=[...])`,然后照常调用工具:
|
||||
|
||||
```python title="client.py" hl_lines="66-68"
|
||||
--8<-- "docs_src/extensions/tutorial006.py"
|
||||
```
|
||||
|
||||
`call_tool("buy", ...)` 返回一个普通的 `CallToolResult`,和其他任何调用一样。扩展改变的是:服务器现在可以用 `receipt` **结果形态**而不是最终结果来回答 `buy`,`Receipts` 会在 `call_tool` 返回之前把它完成(这里是用一次后续调用兑换收据)。调用处什么都不用动。
|
||||
|
||||
去掉这个扩展,这一切就都不存在:服务器的门槛会拒绝没有声明它的客户端(错误 -32021),而跳过门槛的服务器发来的被认领形态会校验失败,正如规范对无法识别的 `resultType` 所要求的那样。默认关闭,线路两端都是。
|
||||
|
||||
要声明一个**没有**任何客户端行为的标识符(服务器按该能力设门槛,客户端什么都不做,就像上面的 search 客户端那样),用 `advertise()`:
|
||||
|
||||
```python
|
||||
from mcp.client import advertise
|
||||
|
||||
client = Client(mcp, extensions=[advertise("com.example/search")])
|
||||
```
|
||||
|
||||
## 编写客户端扩展 {#writing-a-client-extension}
|
||||
|
||||
继承 `ClientExtension`,只重写需要的部分。贡献分三类,各有默认实现:`settings()`、`claims()` 和 `notifications()`。
|
||||
|
||||
```python title="client.py" hl_lines="17-18 43-44 46-47"
|
||||
--8<-- "docs_src/extensions/tutorial006.py"
|
||||
```
|
||||
|
||||
* 标识符遵循与服务器端相同的语法,在类定义时校验。
|
||||
* `claims()` 返回 `ResultClaim`:一个线路标签、解析它的模型,以及完成它的解析器。模型必须用 `result_type: Literal["receipt"]` 固定该标签,且不得继承该动词的核心结果类型;两者都在构造认领时强制检查。像 `receipt_token` 这样的厂商字段在线路上原样传输:被替换的形态会逐字到达客户端。
|
||||
* 解析器接收解析后的模型和一个 `ClaimContext`;`ctx.session` 与 `client.session` 是同一个公开句柄,所以后续操作就是普通的会话调用。它返回该动词正常的 `CallToolResult`。
|
||||
* `settings()` 是在 `ClientCapabilities.extensions[identifier]` 处声明的值,在构造 `Client` 时读取一次。
|
||||
|
||||
`notifications()` 声明要观察的厂商服务器通知:
|
||||
|
||||
```python
|
||||
def notifications(self) -> Sequence[NotificationBinding[Any]]:
|
||||
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
|
||||
```
|
||||
|
||||
处理函数按分发顺序逐个接收校验过的参数。它只观察,不能否决,也不能回复。
|
||||
|
||||
两条不起眼的规则。认领只在 2026-07-28 连接上生效,能力声明随之变化:在旧版连接上,认领会消失,标识符也随之从声明中去掉,所以客户端永远不会声明一个其形态自己会拒绝的扩展。另外,如果想自己拿到被认领的形态而不交给解析器,调用 `client.session.call_tool(..., allow_claimed=True)`;没有这个标志时,被认领的形态到达会话层调用方会抛出 `UnexpectedClaimedResult`。
|
||||
|
||||
### 扩展动词 {#extension-verbs}
|
||||
|
||||
扩展自己的请求方法不需要在客户端注册。厂商请求类型继承 `mcp.types.Request`,通过 `client.session.send_request` 发送,如[提供自己的方法](#serving-your-own-methods)所示。补充一点:当某个参数键必须放进 `Mcp-Name` 头(tasks 之类的扩展规范对其动词有此要求)时,请求类型要声明 `name_param`:
|
||||
|
||||
```python title="client.py" hl_lines="22-25 46-47"
|
||||
--8<-- "docs_src/extensions/tutorial007.py"
|
||||
```
|
||||
|
||||
会话在每条发送路径上都会把 `params["jobId"]` 镜像到 `Mcp-Name` 中,值缺失时会明确报错,而不是悄悄漏掉一个必需的头。
|
||||
|
||||
## 扩展不能做什么 {#what-an-extension-cannot-do}
|
||||
|
||||
贡献面是有意**封闭**的。服务器端:设置、工具、资源、方法、一个 `tools/call` 拦截器。客户端:设置、结果认领、通知绑定。扩展不能:
|
||||
|
||||
* **伸手进宿主内部。**它只声明数据,不持有服务器或客户端的引用。
|
||||
* **替换核心行为。**规范方法和核心结果标签在构造时就被拒绝(`initialize` 更是被运行器直接保留);被核心词汇遮蔽的通知绑定则会安静失效并给出一条警告。
|
||||
* **延迟注册。**`MCPServer(...)` 或 `Client(...)` 返回之后,扩展集合就定了。
|
||||
|
||||
如果你在跟这些墙较劲,那你写的不是扩展,而是一个 fork。墙本身就是特性:用户读到 `extensions=[Apps(), Stamps()]`,就知道这两者可能触碰过的**一切**。
|
||||
22
i18n/zh/pages/advanced/index.md
Normal file
22
i18n/zh/pages/advanced/index.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
translation:
|
||||
sections: [ca6988b7503cd2d3]
|
||||
tool: 1
|
||||
---
|
||||
# 进阶 {#advanced}
|
||||
|
||||
普通服务器或客户端需要的一切,在上面的各节里都有对应的专题位置。这一节是在 `MCPServer` 的便利层碍事时才用得上的后门:
|
||||
|
||||
* **[底层 Server](low-level-server.md)**:`MCPServer` 构建于其上的类。手写模式、`on_*` 处理函数、没有任何替你做的检查,还可以定义你自己的 JSON-RPC 方法。
|
||||
* **[分页](pagination.md)** 和 **[中间件](middleware.md)**:两件**只能**在底层 `Server` 上做的事。
|
||||
* **[扩展](extensions.md)** 和 **[MCP Apps](apps.md)**:协议的扩展面。把扩展包组合进服务器,或者自己写一个。
|
||||
|
||||
有几样东西你可能理所当然地想在这里找,但它们其实放在实际用到它们的地方:
|
||||
|
||||
* **授权**在 **[运行服务器](../run/index.md)** 下,因为服务器是在部署的地方加以保护的。
|
||||
* **OAuth**、**身份断言**、连接**多个服务器**以及响应**缓存**都在 **[客户端](../client/index.md)** 下。
|
||||
* **多轮往返(multi-round-trip)请求**和**订阅**在 **[在处理函数内部](../handlers/index.md)** 下,因为两者都是处理函数**做**的事。
|
||||
* **URI 模板**在 **[服务器](../servers/index.md)** 下,挨着资源。
|
||||
* **[协议版本](../protocol-versions.md)** 和 **[已弃用功能](../deprecated.md)** 各有自己的顶层页面。
|
||||
|
||||
如果不确定自己需不需要这一节,那就是不需要。
|
||||
206
i18n/zh/pages/advanced/low-level-server.md
Normal file
206
i18n/zh/pages/advanced/low-level-server.md
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
---
|
||||
translation:
|
||||
sections: [2c79b6338e09b7ac, 7edc43b3fae11314, 1086e77ce561cd7f, a3f71823df5efc31, 9fc7109f72201cae, 7bf25983df655b66, 6330e1f4c6029683, 2f1749c8c133fa1c, b3530fcf4d11fd56, ebc33704fbd74262, cd0e9c933350390e]
|
||||
tool: 1
|
||||
---
|
||||
# 底层 Server {#the-low-level-server}
|
||||
|
||||
`@mcp.tool()` 是一层封装。它下面还有第二个服务器类 `Server`,说的是原始的 MCP:你把协议对象交给它,它原封不动地放到线路上。
|
||||
|
||||
`MCPServer` 就构建在它之上。当便利层碍事时,才需要下沉到这一层:
|
||||
|
||||
* 需要发出一个**精确**的模式(从文件加载、从数据库生成),而不是从 Python 签名推导出来的模式。
|
||||
* 需要完全掌控结果:`_meta`、`is_error`、`structured_content` 的每一个键。
|
||||
* 需要处理一个 MCP 没有定义的方法。
|
||||
|
||||
其他情况,留在 `MCPServer` 上。
|
||||
|
||||
## 同一个工具,手写版 {#the-same-tool-by-hand}
|
||||
|
||||
这是 **[工具](../servers/tools.md)** 用九行 `@mcp.tool()` 写出的 `search_books` 工具,去掉语法糖之后的样子:
|
||||
|
||||
```python title="server.py" hl_lines="22 26 32"
|
||||
--8<-- "docs_src/lowlevel/tutorial001.py"
|
||||
```
|
||||
|
||||
变了三件事,而它们就是整个底层 API:
|
||||
|
||||
* **处理函数是构造函数参数。** `on_list_tools=` 和 `on_call_tool=` 传进 `Server(...)`。这一层没有装饰器,每个处理函数的形状都一样:`async (ctx, params) -> result`。
|
||||
* **输入模式自己写。** `Tool.input_schema` 是一个普通的 JSON Schema `dict`。没人从类型注解推导它,因为根本没有类型注解可供推导。
|
||||
* **结果自己构建。** `CallToolResult(content=[TextContent(...)])`,手写。没有包装、没有转换,也不会从返回值注解推断任何东西。
|
||||
|
||||
`params` 是解析后的请求:`CallToolRequestParams` 提供 `.name` 和 `.arguments`。`ctx` 是一个 `ServerRequestContext`:`ctx.session` 用来回头和客户端通信,还有 `ctx.lifespan_context`、`ctx.request_id`,以及 `ctx.meta`——请求传入的 `_meta`。
|
||||
|
||||
!!! info
|
||||
如果用过 FastAPI,这层关系你已经熟悉了。`MCPServer` 是装饰器加类型注解的那一层;`Server` 是底下的 Starlette。它们不是竞争关系:`MCPServer` 会构造一个 `Server`,并在上面注册和这里一模一样的处理函数。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
这个没有 Inspector 可用:`mcp dev` 和 `mcp run` 只接受 `MCPServer`。内存中的 `Client` 不在乎;它接收底层 `Server` 的方式和接收 `MCPServer` 完全一样:
|
||||
|
||||
```python title="main.py"
|
||||
import asyncio
|
||||
|
||||
from mcp import Client
|
||||
|
||||
from server import server
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
print(result.content)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```text
|
||||
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
|
||||
```
|
||||
|
||||
和 `@mcp.tool()` 版本产生的文本一样。两处实实在在的差别:
|
||||
|
||||
* `result.structured_content` 是 `None`。高层服务器会替你把 `-> str` 包装成 `{"result": ...}`;在这里,你没构建的东西没人替你构建。
|
||||
* `list_tools` 返回的是**你**敲进去的模式,一字不差。高层版本在每个属性上都有 `"title": "Query"`,根上还有一个 `"title": "search_booksArguments"`:Pydantic 的产物。在这一层,线路上有什么,都是你放上去的。
|
||||
|
||||
## 没有替你做任何检查 {#nothing-is-checked-for-you}
|
||||
|
||||
`MCPServer` 会在你的函数运行之前拒绝错误的参数,按它生成的模式校验调用(**[工具](../servers/tools.md)**)。
|
||||
|
||||
`Server` 不做这件事。你的 `input_schema` 只是向客户端**公布**;它从不会被**应用**到 `params.arguments` 上。
|
||||
|
||||
!!! check
|
||||
调用 `search_books` 时不传 `limit`,你的 `args["limit"]` 就会抛出 `KeyError`。客户端看到的是:
|
||||
|
||||
```text
|
||||
MCPError: Internal server error
|
||||
```
|
||||
|
||||
一个 JSON-RPC 错误,代码 `-32603`,消息故意写得很笼统:SDK 不会把你的 traceback 泄露给远程调用方。模型永远不知道自己哪里做错了,所以也没法重试。(在测试里,`raise_exceptions=True` 会把真实的异常暴露出来;见 **[测试](../get-started/testing.md)**。)
|
||||
|
||||
这一点可以推广。从底层处理函数抛出的异常**永远**是协议错误,绝不会是 `is_error=True` 的工具结果。如果想让模型读到失败信息并恢复,就自己校验 `params.arguments`,然后返回 `CallToolResult(content=[TextContent(...)], is_error=True)`。这两种失败是 **[处理错误](../servers/handling-errors.md)** 的主题。
|
||||
|
||||
## 两个工具,一个处理函数 {#two-tools-one-handler}
|
||||
|
||||
`on_call_tool` 是服务器上所有工具的唯一入口。按 `params.name` 路由:
|
||||
|
||||
```python title="server.py" hl_lines="38-43"
|
||||
--8<-- "docs_src/lowlevel/tutorial002.py"
|
||||
```
|
||||
|
||||
* `list_tools` 公布两个工具。`call_tool` 按名字分发。
|
||||
* `else` 分支很重要:对于一个你从未列出的名字,`Server` 照样会把 `tools/call` 直接转发进你的处理函数。在那里抛异常,调用就会变成和上面一样的 `-32603`。
|
||||
|
||||
## 结构化输出,手写版 {#structured-output-by-hand}
|
||||
|
||||
在 `Tool` 上声明 `output_schema`,在结果上放 `structured_content`。两者都归你管:
|
||||
|
||||
```python title="server.py" hl_lines="19-23 36"
|
||||
--8<-- "docs_src/lowlevel/tutorial003.py"
|
||||
```
|
||||
|
||||
调用它,结果同时携带两种表示:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
|
||||
"structuredContent": {"matches": 3, "query": "dune"},
|
||||
"isError": false,
|
||||
"resultType": "complete",
|
||||
"_meta": {"io.modelcontextprotocol/serverInfo": {"name": "Bookshop", "version": "2.0.0"}}
|
||||
}
|
||||
```
|
||||
|
||||
`_meta` 块是服务器的身份标记:SDK 会把它加到每个 2026 版的结果上,`version` 取自构造函数(没设置的服务器会报告空字符串)。不能暴露身份的服务器可以用中间件把这个键去掉,中间件拥有它返回的结果。
|
||||
|
||||
服务器从不比较这两个字段。本 SDK 的 `Client` 会:返回的 `structured_content` 不满足你声明的 `output_schema` 时,`call_tool` 会抛出一个 `RuntimeError`,开头是 `Invalid structured content returned by tool search_books`,后面引用 `jsonschema` 的失败信息。承诺一个模式很便宜;信守它是你的事。返回类型和模式的完整阶梯详见 **[结构化输出](../servers/structured-output.md)**。
|
||||
|
||||
## `_meta`:给应用程序,不是给模型 {#\_meta-for-the-application-not-the-model}
|
||||
|
||||
`content` 是答案里模型读取的部分。`structured_content` 是同一个答案的类型化数据形式。`_meta` 是第三条通道:随结果一起传递、面向**客户端应用程序**的数据,根本不属于答案的一部分。
|
||||
|
||||
用它放记录 ID、追踪 ID,以及任何 UI 需要而提示词不需要的东西:
|
||||
|
||||
```python title="server.py" hl_lines="37"
|
||||
--8<-- "docs_src/lowlevel/tutorial004.py"
|
||||
```
|
||||
|
||||
* 构造时写作 `_meta=`,也就是线路上的名字。客户端读回来是 `result.meta`。
|
||||
* 给键加命名空间(`bookshop/record_ids`)。`io.modelcontextprotocol/*` 键由协议保留。
|
||||
|
||||
!!! warning
|
||||
`_meta` 是你和客户端应用程序之间的约定,不是对哪些内容会到达模型的保证。宿主决定渲染什么。永远不要把秘密放进工具结果的任何部分。
|
||||
|
||||
## 能力跟着处理函数走 {#capabilities-follow-your-handlers}
|
||||
|
||||
`Server` 公布的恰好是你给了处理函数的那些方法族。上面的 `Bookshop` 只传了 `on_list_tools` 和 `on_call_tool`,别的都没有,所以连接它的客户端看到的是:
|
||||
|
||||
```json
|
||||
{"tools": {"listChanged": false}}
|
||||
```
|
||||
|
||||
没有 `resources`,没有 `prompts`:没有东西支撑它们。传入 `on_list_prompts`,`prompts` 就出现;传入 `on_completion`,`completions` 就出现。
|
||||
|
||||
`MCPServer` 总是公布工具、资源和提示词,不管你有没有注册,因为它的管理器总是存在。在这一层,声明**就是**那次构造函数调用。
|
||||
|
||||
## 生命周期泛型 {#the-lifespan-generic}
|
||||
|
||||
`Server` 在生命周期产出的类型上是泛型的。注解一次,这个对象在出现的每个地方都有类型:
|
||||
|
||||
```python title="server.py" hl_lines="24-26 44-45 50"
|
||||
--8<-- "docs_src/lowlevel/tutorial005.py"
|
||||
```
|
||||
|
||||
* 生命周期是一个 `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`;在 `async` 生成器上加 `@asynccontextmanager` 正好得到它。
|
||||
* 它 `yield` 的东西成为 `ctx.lifespan_context`,又因为处理函数注解为 `ServerRequestContext[Catalog]`,`.search(...)` 能自动补全并通过类型检查。
|
||||
* 服务器启动时进入一次,停止时退出一次。启动、清理,以及 `MCPServer` 对同一思路的实现,详见 **[生命周期](../handlers/lifespan.md)**。
|
||||
|
||||
没有 `lifespan=` 时,`ctx.lifespan_context` 是一个空 `dict`。
|
||||
|
||||
## 自己的方法 {#a-method-of-your-own}
|
||||
|
||||
构造函数覆盖 MCP 定义的方法。`add_request_handler` 覆盖其余所有:
|
||||
|
||||
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
|
||||
--8<-- "docs_src/lowlevel/tutorial006.py"
|
||||
```
|
||||
|
||||
* 第一个参数是方法字符串。通知有一个对应的 `add_notification_handler`。
|
||||
* `params_type` 是传入的 `params` 在处理函数运行**之前**校验所依据的模型,所以自定义方法**确实**得到了工具没有的校验。继承 `RequestParams`,这样 `_meta` 字段的解析方式和其他方法一样。
|
||||
* 处理函数返回 `BaseModel`、`dict` 或 `None`。SDK 把它序列化进 JSON-RPC 结果。
|
||||
|
||||
一个实实在在的提醒:高层 `Client` 只为 MCP 定义的方法提供了动词,所以没有 `client.reindex()`。厂商方法是给已经知道它存在的对端用的:你同时发布的客户端,或者你自己说 JSON-RPC 的另一个服务。
|
||||
|
||||
有一个方法你不能占用:
|
||||
|
||||
```text
|
||||
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
|
||||
use Server.middleware to observe or wrap initialization
|
||||
```
|
||||
|
||||
握手归运行器所有。`server/discover`、`ping` 以及其他所有内置方法都可以替换。
|
||||
|
||||
!!! tip
|
||||
那条错误里提到的 `Server.middleware` 会包裹**每一条**入站消息,包括 `initialize`。如果想要的是观察或改写流量,而不是响应一个新方法,从 **[中间件](middleware.md)** 开始。
|
||||
|
||||
## 其他处理函数 {#the-other-handlers}
|
||||
|
||||
下面每一项都是一个你现在已经有词汇去理解的概念;每一项都有自己的页面。
|
||||
|
||||
* `on_call_tool`、`on_get_prompt` 和 `on_read_resource` 可以返回 `InputRequiredResult` 而不是正常结果,来暂停调用并向客户端索要输入;见 **[多轮往返(multi-round-trip)请求](../handlers/multi-round-trip.md)**。符合这一层的风格,没有任何东西替你装好:`MCPServer` 默认会密封 `requestState`,而在这里,你设置的 `request_state` 按原样穿过线路,直到你用 `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))` 主动启用:一行代码(两个名字都从 `mcp.server.request_state` 导入),得到和 `MCPServer` 完全相同的密封与验证(**[保护 `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**)。
|
||||
* `on_list_resources`、`on_read_resource`、`on_list_prompts`、`on_get_prompt`、`on_completion` 是针对其他原语的同样 `(ctx, params) -> result` 形状。
|
||||
* `on_subscriptions_listen` 提供 2026-07-28 的 `subscriptions/listen` 流。传入一个构建在 `SubscriptionBus` 之上的 `ListenHandler`,并从其他处理函数向总线发布事件;完整的组合方式见 **[订阅](../handlers/subscriptions.md)**。
|
||||
* `server.streamable_http_app()` 返回的 Starlette 应用和 `MCPServer` 的一样;按 **[运行你的服务器](../run/index.md)** 部署任何其他 ASGI 应用的方式部署它。这一层没有 `server.run(transport=...)`:`server.run(read_stream, write_stream, server.create_initialization_options())` 在一对流上驱动一个连接,整件事就是这一行。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 底层 `Server` 以 `on_*` **构造函数参数**接收处理函数;每个处理函数都是 `async (ctx, params) -> result`。
|
||||
* `input_schema` 字典自己写,`CallToolResult` 自己构建。没有任何东西替你推导、包装或校验。
|
||||
* 处理函数里的异常是 `-32603` 协议错误。模型能读到的工具错误是**你**返回的 `is_error=True` 的 `CallToolResult`。
|
||||
* 结果上的 `_meta` 面向客户端应用程序,不是模型。
|
||||
* `Server[T]` 在生命周期产出的东西上是泛型的;`ctx.lifespan_context` 是有类型的 `T`。
|
||||
* `add_request_handler(method, params_type, handler)` 提供任意方法。`initialize` 是保留的。
|
||||
* `Server` 公布的能力由你注册了哪些处理函数推导而来。
|
||||
|
||||
`Client(server)` 对两种服务器一视同仁,因为它们**就是**同一个协议,这正是关键所在。再往下一层根本不是一个类:它是 **[中间件](middleware.md)**。
|
||||
84
i18n/zh/pages/advanced/middleware.md
Normal file
84
i18n/zh/pages/advanced/middleware.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
translation:
|
||||
sections: [6048b4f308edbb8c, 068bda0f21ee9c1b, c3e565b61acd75c5, c62422b159c6ed09, 47204fab253cc45c]
|
||||
tool: 1
|
||||
---
|
||||
# 中间件 {#middleware}
|
||||
|
||||
**中间件**(middleware)是一个异步函数,它包裹服务器收到的每一条消息。
|
||||
|
||||
把它写成 `async (ctx, call_next)`,再追加到 `server.middleware` 里。整个 API 就这些。
|
||||
|
||||
!!! warning
|
||||
中间件列表在源码中标记为**临时性**(provisional):它的签名和语义可能在某个 2.x 次版本中改变。用它来**观察**(计时、记录日志、追踪)和**拒绝**消息;不要把它当作服务器赖以立足的根基。
|
||||
|
||||
`MCPServer` 在构造时接收这个列表(`MCPServer(name, middleware=[...])`),并以 `mcp.middleware` 暴露出来;低层 `Server` 把同一个列表暴露为 `server.middleware`。下面的示例用的是低层 `Server`;如果你还没见过 `Server(name, on_call_tool=...)`,先读 **[低层 Server](low-level-server.md)**。
|
||||
|
||||
## 一个计时中间件 {#a-timing-middleware}
|
||||
|
||||
一个服务器、一个工具、一个中间件,记录每条消息花了多长时间:
|
||||
|
||||
```python title="server.py" hl_lines="39-45 49"
|
||||
--8<-- "docs_src/middleware/tutorial001.py"
|
||||
```
|
||||
|
||||
* `ctx` 就是处理函数收到的那个 `ServerRequestContext`。`ctx.method` 是原始的方法字符串;`ctx.params` 是原始参数,**尚未**经过任何校验。
|
||||
* `call_next(ctx)` 运行链条剩下的部分:校验、查找处理函数、你的处理函数。把它的返回值原样返回,响应就不会被改动。
|
||||
* `try`/`finally` 是有意为之:抛出异常的处理函数照样会被计时,因为失败会以 `call_next` 抛出的异常的形式到达你的中间件。
|
||||
* `server.middleware.append(...)` 完成注册。列表按从外到内的顺序执行,所以 `middleware[0]` 是离线路最近的那一个。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
连接一个客户端,列出工具,调用其中一个。日志里有**三**行:
|
||||
|
||||
```text
|
||||
server/discover took 18.3 ms
|
||||
tools/list took 0.1 ms
|
||||
tools/call took 0.1 ms
|
||||
```
|
||||
|
||||
你发了两次调用,却得到三行。第一行是 `server/discover`:客户端为建立连接而发送的请求,在你提出任何要求之前就发出了。
|
||||
|
||||
这正是关键所在。中间件包裹**每一条**入站消息:
|
||||
|
||||
* 连接建立:`server/discover`,或者旧版会话上的 `initialize` 和 `notifications/initialized`。
|
||||
* 每一个请求和每一个通知。对于通知,`ctx.request_id is None`,`call_next(ctx)` 返回 `None`,而你返回的任何东西都会被丢弃。
|
||||
* 甚至包括服务器没有处理函数的方法:`call_next` 会抛出 `MCPError(-32601, "Method not found")`,**穿过**你的中间件送往客户端。
|
||||
|
||||
## 在中间件里能做什么 {#what-you-can-do-inside-one}
|
||||
|
||||
按你应当犹豫的程度递增排列:
|
||||
|
||||
* **观察。**计时、计数、记录日志。就是上面的例子。
|
||||
* **拒绝。**抛出一个 `MCPError` 来**代替**调用 `call_next(ctx)`,这一条消息就会以 JSON-RPC 错误作答。连接保持不断;下一条消息照常通过。服务器就是这样按调用方对 `subscriptions/listen` 设限的:订阅页面的 **[决定谁可以监听](../handlers/subscriptions.md#deciding-who-may-watch)** 一节有完整的讲解。
|
||||
* **改写。**`ctx` 是一个 dataclass:`await call_next(dataclasses.replace(ctx, params=...))` 会把与客户端所发不同的参数交给链条剩下的部分。永远不要对 `initialize` 这样做:客户端拿到的结果是根据你改写后的参数构建的,但服务器提交连接状态时依据的是线路上的原始参数。两端可能在握手结束时对协商结果各执一词。
|
||||
* **作答。**不调用 `call_next(ctx)` 而直接返回一个结果,它就会作为你的响应发给客户端。`call_next` 交给你的是最终的线路形式,而流水线从不修补你返回的内容,所以整个信封都由你负责:在 2026 年代的连接上,这包括 `serverInfo` 的 `_meta` 戳记——SDK 会给处理函数的结果加上它,但不会给你的结果加。
|
||||
|
||||
!!! check
|
||||
`initialize` 也是中间件包裹的对象之一,而且中间件是你能拿到的**唯一**钩子。试图用 `add_request_handler` 接管它,SDK 会拒绝:
|
||||
|
||||
```text
|
||||
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
|
||||
use Server.middleware to observe or wrap initialization
|
||||
```
|
||||
|
||||
!!! warning
|
||||
`initialize` 是内联处理的:在你的中间件链返回之前,服务器不会再读取任何入站消息。因此,在处理 `initialize` 期间等待一个服务器到客户端的请求(`ctx.session.send_request(...)`、一次征询(elicitation))会**让连接死锁**:你在等待的响应永远无法被读到。发后即忘的通知没有问题。
|
||||
|
||||
## 唯一一个默认启用的中间件 {#the-one-middleware-that-ships-on-by-default}
|
||||
|
||||
SDK 自带的中间件恰好只有一个,而且已经在你服务器的列表上了:为每条消息发出一个 OpenTelemetry span 的那个。你不用追加它,大多数时候也不用去想它。在你安装导出器之前它什么都不做,它有自己的页面:**[OpenTelemetry](../run/opentelemetry.md)**。
|
||||
|
||||
!!! info
|
||||
如果你写过 ASGI 中间件,这个形状你已经认识了。Starlette 的 `(scope, receive, send)` 变成了 `(ctx, call_next)`,而且它运行在传输**之后**,作用于解码后的消息而不是原始 HTTP 请求。两者可以组合:挂在 `streamable_http_app()` 上的 Starlette 中间件看到的是 HTTP;这里看到的是 MCP。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 中间件是 `async (ctx, call_next) -> result`,以 `MCPServer(middleware=[...])` 传入(或追加到 `mcp.middleware`),在低层 `Server` 上则追加到 `server.middleware`。
|
||||
* 它包裹**每一条**入站消息(`server/discover`、`initialize`、请求、通知、未知方法),按从外到内的顺序执行。
|
||||
* 用 `ctx.request_id is None` 区分通知和请求。
|
||||
* 抛出异常而不调用 `call_next` 即可拒绝一条消息;连接不受影响。
|
||||
* SDK 自己的 OpenTelemetry 追踪也是一个中间件,已经在列表上了。见 **[OpenTelemetry](../run/opentelemetry.md)**。
|
||||
* 整个接口都是临时性的。用它来观察;不要在它之上构建。
|
||||
|
||||
包裹请求的东西就这些了。**[授权](../run/authorization.md)** 决定的则是请求究竟能不能运行。
|
||||
81
i18n/zh/pages/advanced/pagination.md
Normal file
81
i18n/zh/pages/advanced/pagination.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
translation:
|
||||
sections: [a9aba7a026c7bd85, ed32bda7ba9ae33a, 7e64cc5646abb91f, 22a0129ee78b3c63, d875373c06d8d2f9]
|
||||
tool: 1
|
||||
---
|
||||
# 分页 {#pagination}
|
||||
|
||||
大多数服务器永远用不到这个。
|
||||
|
||||
`MCPServer` 对每个 `list_*` 请求都一次返回它拥有的全部内容,只有一页,`next_cursor=None`。对于几十个工具、资源或提示词来说,这就是正确答案,没有什么需要配置的。
|
||||
|
||||
分页是给那种资源列表其实是一个数据库的服务器准备的:几千行数据,它不肯在一个响应里全部序列化。协议给出的答案是**游标(cursor)**:服务器返回一页数据外加一个不透明的令牌,客户端把这个令牌发回去,就能拿到下一页。
|
||||
|
||||
`@mcp.resource()` 没有为这些提供任何钩子。要分页,就得在 **[底层 Server](low-level-server.md)** 上自己写 list 处理函数。
|
||||
|
||||
## 会分页的服务器 {#a-server-that-pages}
|
||||
|
||||
```python title="server.py" hl_lines="12 15-16"
|
||||
--8<-- "docs_src/pagination/tutorial001.py"
|
||||
```
|
||||
|
||||
* 在底层 `Server` 上,处理函数是构造函数参数,而不是装饰器。`on_list_resources` 响应每一个 `resources/list` 请求;整个接入就这些。
|
||||
* 每个分页处理函数的类型标注都是 `params: PaginatedRequestParams | None`,示例对两种情况都做了处理。不过在连接上,SDK 永远不会交给你 `None`(没有 `params` 成员的请求到达处理函数时,是带默认值的模型实例),所以真正重要的信号是 `params.cursor is None`:**从头开始**。
|
||||
* 游标**是**什么由你决定。这里是转成字符串的偏移量。时间戳、主键、base64 数据块:只要发出去时能生成、收回来时能认出,什么都行。
|
||||
* `next_cursor=None` 表示“那是最后一页”。没有计数,没有总数,没有 `has_more`。`None` 就是全部信号。
|
||||
|
||||
!!! tip
|
||||
`PAGE_SIZE` 设为 10 是为了让示例好读。按端点选你自己的:一行一个的资源列表,一页放 500 个也负担得起;一堆臃肿的提示词模板列表就不行。客户端对此没有发言权,这是有意为之。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
`Client(server)` 在内存中连接底层 `Server` 的方式,和连接 `MCPServer` 完全一样。
|
||||
|
||||
不带参数调用 `list_resources()`。得到十个资源,从 `book-1` 到 `book-10`,`next_cursor` 是字符串 `"10"`。
|
||||
|
||||
用 `list_resources(cursor="10")` 把它交回去,第一个资源就是 `book-11`,新的 `next_cursor` 是 `"20"`。
|
||||
|
||||
第十页回来时 `next_cursor` 为 `None`。结束。
|
||||
|
||||
## 客户端循环 {#the-client-loop}
|
||||
|
||||
`Client` 上的每个 `list_*` 方法(`list_tools`、`list_resources`、`list_resource_templates`、`list_prompts`)都接受一个 `cursor=` 关键字参数。取完一个分页列表只需要一个 `while True`:
|
||||
|
||||
```python title="client.py" hl_lines="26-32"
|
||||
--8<-- "docs_src/pagination/tutorial002.py"
|
||||
```
|
||||
|
||||
* `cursor` 起始为 `None`,所以第一个请求不带游标。
|
||||
* 先 extend,**再**看 `next_cursor`:最后一页也有资源。
|
||||
* `next_cursor is None` 是出口。其他任何值都原封不动地直接塞回 `cursor=`。
|
||||
|
||||
运行它的 `main()`,会打印 `100 resources`:十页、每页十个,由一个从头到尾都不知道有十页的循环拼在一起。
|
||||
|
||||
这和 **[客户端](../client/index.md)** 为每个 `list_*` 动词展示的是同一个循环,而且面对不分页的服务器也没有任何代价:第一个响应里 `next_cursor` 就是 `None`,循环只跑一次。
|
||||
|
||||
## 三条规则 {#the-three-rules}
|
||||
|
||||
**游标是不透明的。** 客户端绝不能解析、构造或猜测游标。游标唯一合法的来源是上一页的 `next_cursor`,一字不改。
|
||||
|
||||
**页大小由服务器决定。** 协议里没有 `limit=`。需要不同的页大小,就改服务器。
|
||||
|
||||
**忽略分页的客户端照样能用。** 它调用一次 `list_resources()`,拿到前十个,从没注意到自己扔掉的 `next_cursor`。什么都没坏;只是看到的少一些。
|
||||
|
||||
!!! check
|
||||
不透明就是不透明。自己编一个游标(`list_resources(cursor="page-2")`),协议帮不了你任何忙。这个服务器会尝试 `int("page-2")`,处理函数抛出异常,回到客户端的是:
|
||||
|
||||
```text
|
||||
MCPError(-32603, 'Internal server error', None)
|
||||
```
|
||||
|
||||
不是从服务器拿到的游标是 bug,不是功能请求。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* `MCPServer` 把所有内容放在一页里返回。分页需要主动启用,启用的地方是底层 `Server`。
|
||||
* `on_list_resources`(以及 `on_list_tools`、`on_list_prompts`、`on_list_resource_templates`)接收 `PaginatedRequestParams | None`;第一页时 `params.cursor` 为 `None`。
|
||||
* 返回一页外加 `next_cursor`:任何以后能认出来的字符串,或者在没有剩余内容时返回 `None`。
|
||||
* 客户端循环:传入 `cursor=`,累积,重复直到 `next_cursor is None`。
|
||||
* 游标不透明,页大小归服务器管,不分页的客户端仍然能拿到第一页。
|
||||
|
||||
手写 `Server` API 的其余部分(`on_call_tool`、`input_schema` 字典、`_meta`)见 **[底层 Server](low-level-server.md)**。
|
||||
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)**。
|
||||
86
i18n/zh/pages/deprecated.md
Normal file
86
i18n/zh/pages/deprecated.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
translation:
|
||||
sections: [20541a40dbdd5980, 01262a123ad9501d, 429db5b574a2ac08, 56b2d49da412cb28, 6a1717123fe4513c]
|
||||
tool: 1
|
||||
---
|
||||
# 已弃用的功能 {#deprecated-features}
|
||||
|
||||
2026-07-28 规范让五项内容退役。SDK 仍然实现了其中每一项,而且每一项现在都带有**弃用警告**。
|
||||
|
||||
下表列出了每一项已弃用的功能、它为什么要退场,以及应该改用的替代方案。
|
||||
|
||||
## 弃用了什么 {#what-is-deprecated}
|
||||
|
||||
| 已弃用 | 原因 | 替代做法 |
|
||||
|---|---|---|
|
||||
| **根目录(roots)**:`ctx.session.list_roots()`、`client.send_roots_list_changed()`、传给 `Client(...)` 的 `list_roots_callback=` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) 弃用了这一能力。 | 把路径作为普通的工具参数或资源 URI 传入,或者在 `InputRequiredResult` 中嵌入一个 `ListRootsRequest`(见 **[多轮往返(multi-round-trip)请求](handlers/multi-round-trip.md)**)。 |
|
||||
| **服务器发起的采样(sampling)**:`ctx.session.create_message()`、传给 `Client(...)` 的 `sampling_callback=` | SEP-2577 弃用了这一能力。 | 返回 `InputRequiredResult`,让客户端重试该调用(见 **[多轮往返请求](handlers/multi-round-trip.md)**)。 |
|
||||
| **协议日志**:`ctx.log()`、`ctx.debug()`、`ctx.info()`、`ctx.warning()`、`ctx.error()`、`ctx.session.send_log_message()`、`client.set_logging_level()` | SEP-2577 弃用了这一能力。协议内没有任何替代。 | 用普通的 `import logging` 输出到 stderr(见 **[日志](handlers/logging.md)**)。 |
|
||||
| **`ping`**:`client.send_ping()` | 从协议中**移除**,而不仅仅是弃用。2026-07-28 中没有 `ping` 方法。 | 无。它只在 `mode="legacy"` 连接上有效。 |
|
||||
| **客户端->服务器进度**:`client.send_progress_notification()` | 2026-07-28 规定进度只能由服务器发往客户端。 | 没有什么可发送的。你的**服务器**用 `ctx.report_progress()` 报告进度(见 **[进度](handlers/progress.md)**)。 |
|
||||
|
||||
从这张表可以看出三点:
|
||||
|
||||
* 根目录、采样和日志是一起的。一份提案 **SEP-2577** 一次性弃用了这三项能力。
|
||||
* 采样和根目录有一个更深层的共同问题:它们都是**服务器**向**客户端**发送**请求**的地方。2026-07-28 用 **[多轮往返请求](handlers/multi-round-trip.md)** 取代的正是这整个方向。消失的是独立的 RPC 方法(`sampling/createMessage`、`roots/list` 和推送式的 `elicitation/create`);`CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` 这些载荷类型保留了下来,嵌入在 `InputRequiredResult.input_requests` 中,在客户端它们触发的还是同样的回调。
|
||||
* `ping` 是个例外。协议不是弃用它,而是移除它。SDK 的方法仍会发出警告(消息里写的是“removed”,而不是“deprecated”),在现代连接上调用它会得到“Method not found”的回应。
|
||||
|
||||
## 弃用只是建议性的 {#deprecated-is-advisory}
|
||||
|
||||
今天什么都不会坏。
|
||||
|
||||
上面的每个方法在任何协商为 **2025-11-25 或更早版本**的会话上都能继续工作。在客户端固定 `mode="legacy"`,得到的就是 2026 之前的行为,分毫不差。线路上没有任何变化,能力协商也没有变。
|
||||
|
||||
变化在于,每个方法第一次运行时你会看到一条醒目的警告:
|
||||
|
||||
```text
|
||||
MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
|
||||
```
|
||||
|
||||
`MCPDeprecationWarning` 继承自 `UserWarning`,而**不是** `DeprecationWarning`。这是有意为之:Python 的默认过滤器只在直接作为 `__main__` 运行的代码中显示 `DeprecationWarning`,库就是这样弃用东西、然后两年都没人注意到的。这个警告到处都会显示,不需要 `-W` 标志。
|
||||
|
||||
!!! warning
|
||||
“建议性”止于线路。采样和根目录是服务器发往客户端的**请求**,而 2026-07-28 会话没有承载这类请求的通道。在现代连接上的工具里调用 `ctx.session.create_message()`,警告照样触发,然后发送失败并报错:
|
||||
|
||||
```text
|
||||
Cannot send 'sampling/createMessage': this transport context has no back-channel
|
||||
for server-initiated requests.
|
||||
```
|
||||
|
||||
两个信号,按这个顺序。`MCPDeprecationWarning` 在你调用方法的那一刻触发,任何连接上都是如此。错误是 SDK 随后尝试发送时返回的结果。这两个功能只有在 `mode="legacy"` 连接上、且客户端注册了对应回调时,才能端到端地工作。
|
||||
|
||||
## 屏蔽警告 {#silencing-the-warning}
|
||||
|
||||
新代码里不要这样做。
|
||||
|
||||
但如果你维护的服务器确实在为 2026 之前的客户端提供服务,它完全有理由要一份安静的日志。在第一个已弃用调用运行之前过滤掉这个类别:
|
||||
|
||||
```python
|
||||
import warnings
|
||||
|
||||
from mcp import MCPDeprecationWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
|
||||
```
|
||||
|
||||
整个 API 就这些。没有按方法的开关,你也不需要:只用一个类别的意义就在于,一行代码让它静音,一行代码把它恢复。
|
||||
|
||||
!!! check
|
||||
反过来用这个过滤器,就白得一个回归测试。在 pytest 配置的 `filterwarnings` 设置里加上 `"error::mcp.MCPDeprecationWarning"`,已弃用的调用就会**抛出异常**而不是发出警告。一个名为 `old_log`、仍在调用 `ctx.info()` 的工具不再通过,转而报告:
|
||||
|
||||
```text
|
||||
Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
|
||||
```
|
||||
|
||||
一行 pytest 配置,已弃用的调用就再也不可能悄悄溜回你的代码库而不让测试失败。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* 2026-07-28 规范弃用了**根目录**、服务器发起的**采样**和协议**日志**(都出自 [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)),把**进度**限制为只能由服务器发往客户端,并移除了 **`ping`**。
|
||||
* 替代做法那一列为你指明了去处:采样和根目录看 **[多轮往返请求](handlers/multi-round-trip.md)**,日志看 **[日志](handlers/logging.md)**,进度看 **[进度](handlers/progress.md)**。`ping` 什么都不需要。
|
||||
* 弃用只是建议性的:线路上没有变化,在 2026 之前的会话上一切照常工作,你会看到一条醒目的 `MCPDeprecationWarning`(它是 `UserWarning`,所以默认开启)。
|
||||
* 采样和根目录还需要一条反向通道(back-channel),而 2026-07-28 会话没有。在现代连接上,它们先警告,然后抛出异常。
|
||||
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` 让整个类别静音;pytest 中的 `"error::mcp.MCPDeprecationWarning"` 把它变成测试失败。
|
||||
* 新代码不应建立在其中任何一项之上。
|
||||
|
||||
本文档的其他每一页讲的都是当前的 API。
|
||||
139
i18n/zh/pages/get-started/first-steps.md
Normal file
139
i18n/zh/pages/get-started/first-steps.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
---
|
||||
translation:
|
||||
sections: [0d6c05bcbf836bf3, 59a7b14eeefc68c1, 7114d8d6daba203f, e8bbb56a98ba7bc9, 5138010f6159901c, f78da7c7c363d4c6, 220a939cab348686]
|
||||
tool: 1
|
||||
---
|
||||
# 第一步 {#first-steps}
|
||||
|
||||
**[首页](../index.md)** 节奏很快:写一个服务器,运行它,调用一个工具。
|
||||
|
||||
这一页慢慢来:服务器能暴露的三样东西全都讲到,沿途遇到的每个概念也都给出名字。
|
||||
|
||||
## 宿主、客户端和服务器 {#host-client-and-server}
|
||||
|
||||
从这里开始,每一页都会见到这三个词:
|
||||
|
||||
* **宿主** 是 LLM 应用:Claude、IDE、智能体运行时。用户与之对话的就是它。
|
||||
* **客户端** 位于宿主内部,讲 MCP。宿主每连接一个服务器,就运行一个客户端。
|
||||
* **服务器** 是你用这个 SDK 构建的东西。它向客户端暴露内容,从不直接和模型对话。
|
||||
|
||||
你写的是服务器。宿主是别人的产品。SDK 还提供了一个 `Client`,你会用它来测试自己的服务器,本页后面就会用到。
|
||||
|
||||
## 三种原语 {#the-three-primitives}
|
||||
|
||||
服务器暴露的东西恰好有三种。区分它们的标准是 **谁来决定使用它们**:
|
||||
|
||||
| 原语 | 由谁控制 | 是什么 | 示例 |
|
||||
|------------|----------|--------------------------------|---------------------------|
|
||||
| **工具** | 模型 | 模型为执行操作而调用的函数 | 一次 API 调用、一次数据库写入 |
|
||||
| **资源** | 应用 | 宿主加载进模型上下文的数据 | 文件内容、API 响应 |
|
||||
| **提示词** | 用户 | 用户按名称调用的可复用消息模板 | 斜杠命令、菜单项 |
|
||||
|
||||
“由谁控制”正是这样划分的全部意义。工具会运行,是因为 **模型** 决定调用它。资源会被附加进来,是因为 **应用** 认为模型需要它。提示词会运行,是因为 **用户** 选了它。
|
||||
|
||||
!!! info
|
||||
如果你做过 Web API,大部分直觉其实已经有了:**资源** 相当于 `GET`(加载数据,什么都不改),**工具** 相当于 `POST`(干活,可能有副作用)。**提示词** 在 HTTP 里没有对应物,它更接近一个用户按名称运行的已保存查询。
|
||||
|
||||
## 一个服务器,三样俱全 {#one-server-all-three}
|
||||
|
||||
```python title="server.py" hl_lines="6 12 18"
|
||||
--8<-- "docs_src/first_steps/tutorial001.py"
|
||||
```
|
||||
|
||||
三个普通函数,三个装饰器。每个装饰器就是注册的全部:
|
||||
|
||||
* `@mcp.tool()` 把 `add` 变成 **工具**。
|
||||
* `@mcp.resource("greeting://{name}")` 把 `greeting` 变成 **资源模板**:URI 里的 `{name}` 就是函数的参数。
|
||||
* `@mcp.prompt()` 把 `summarize` 变成 **提示词**。它返回的字符串会成为一条用户消息。
|
||||
|
||||
其余的一切(名称、描述、参数模式),SDK 都从函数本身读取:函数名、文档字符串、类型注解。这些你都没有单独声明过。
|
||||
|
||||
!!! tip
|
||||
SDK 的两半各有一条导入路径:`from mcp import Client` 和 `from mcp.server import MCPServer`。不存在 `from mcp import MCPServer` 这种写法。
|
||||
|
||||
### 试一试 {#try-it}
|
||||
|
||||
用 MCP Inspector 运行它:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
打开它打印出来的 URL。Inspector 为每种原语各设一个标签页,按顺序逐个看一遍。
|
||||
|
||||
**工具。** 只有一项:`add`,描述是“Add two numbers.”。表单里有一个必填的整数字段 `a`,另一个是 `b`。填好后调用,结果是 `3`。这张表单是 Inspector 根据 `a: int, b: int` 生成的。其他所有客户端也都这样做。
|
||||
|
||||
**资源。** “Resources”列表是空的。`greeting` 在 **Resource Templates** 下面,因为 `greeting://{name}` 带有参数:在有人给出 `name` 之前,没有哪个具体的资源可以列出。填入 `World` 并读取:
|
||||
|
||||
```text
|
||||
Hello, World!
|
||||
```
|
||||
|
||||
**提示词。** 只有一项:`summarize`,带一个必填参数 `text`。传一段文本去获取它,会收到一条 `role: user` 的消息,内容就是你渲染出的字符串。提示词就是这么回事:一个构建消息的函数。
|
||||
|
||||
Inspector 是通过 **stdio** 运行你的服务器的,这是 MCP 服务器可用的传输方式之一。现在还不用选;**[运行服务器](../run/index.md)** 专门讲这个。
|
||||
|
||||
## 能力 {#capabilities}
|
||||
|
||||
你在 Inspector 里看到了三个标签页。它怎么知道有三个?
|
||||
|
||||
客户端连接时,服务器会声明自己的 **能力**:它会响应哪几类请求。客户端根据这份声明来决定该请求什么。这份声明你从没写过;是 `MCPServer` 替你声明的。
|
||||
|
||||
自己看一下。SDK 的 `Client` 可以直接接受服务器对象,并在 **内存中** 与之连接(没有子进程,没有端口):
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(mcp) as client:
|
||||
print(client.server_capabilities.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```text
|
||||
{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}}
|
||||
```
|
||||
|
||||
这个字典就是你的服务器所声明的 **能力**。每个连接上来的客户端最先得知的就是它:
|
||||
|
||||
| 能力 | 客户端现在可以调用 |
|
||||
|-------------|------------------------------------------------------------|
|
||||
| `tools` | `tools/list`, `tools/call` |
|
||||
| `resources` | `resources/list`, `resources/templates/list`, `resources/read` |
|
||||
| `prompts` | `prompts/list`, `prompts/get` |
|
||||
|
||||
`MCPServer` 三种原语都提供,所以这三项始终都会声明。
|
||||
|
||||
注意这里缺了什么。`completions`(资源模板和提示词的参数自动补全)需要一个由你编写的处理函数,而这个服务器没有,所以这项能力不会出现,行为规范的客户端也就不会去问。所有可选项都遵循这条规则:注册了对应的东西,能力就出现;**[补全](../servers/completions.md)** 会证明这一点。
|
||||
|
||||
!!! info
|
||||
`Client(mcp)` 正是这些文档里每个示例测试时所用的那个内存客户端,你测试自己的服务器也会用它。它有整整一页:**[测试](testing.md)**。
|
||||
|
||||
## 你没有写的东西 {#what-you-did-not-write}
|
||||
|
||||
回头看看这一页。你写了三个小小的 Python 函数。你 **没有** 写:
|
||||
|
||||
* JSON Schema。`a: int, b: int` **就是** `add` 的模式。
|
||||
* 请求处理函数。`tools/list`、`resources/read`、`prompts/get`:全都替你处理好了。
|
||||
* 能力声明。`MCPServer` 替你生成了。
|
||||
* 一行协议代码。版本协商、JSON-RPC 分帧、能力交换:全都发生在 `mcp dev` 和 `Client(mcp)` 内部,你一眼都没见到。
|
||||
|
||||
这个比例,正是这个 SDK 的意义所在。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* **宿主** 是 LLM 应用,**客户端** 是它讲 MCP 的那一半,**服务器** 是你构建的东西。
|
||||
* 工具由 **模型** 控制,资源由 **应用** 控制,提示词由 **用户** 控制。
|
||||
* 每种原语一个装饰器:`@mcp.tool()`、`@mcp.resource(uri)`、`@mcp.prompt()`。名称、描述和模式都来自函数本身。
|
||||
* 带 `{param}` 的 URI 生成的是资源 **模板**,与具体资源分开列出。
|
||||
* 服务器的 **能力** 会替你声明好,而客户端只会请求服务器声明过的内容。
|
||||
* `Client(mcp)` 在内存中连接服务器对象:从第一天起,它就是你的测试工具。
|
||||
|
||||
接下来是 **[连接到真实宿主](real-host.md)**:把这个服务器真正放进 Claude Desktop 或 IDE 里。然后是 **[测试](testing.md)**:一页内容,一个内存客户端,从此不用再猜它到底能不能用。再之后,每种原语各有自己的一页,从模型驱动的那一种开始:**[工具](../servers/tools.md)**。
|
||||
53
i18n/zh/pages/get-started/index.md
Normal file
53
i18n/zh/pages/get-started/index.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
translation:
|
||||
sections: [ed4a756b4c53c585, 97e2fb315b7fe398, 4d04f1c6f4bf6c1d, 577d73078fc62baf]
|
||||
tool: 1
|
||||
---
|
||||
# 快速开始 {#get-started}
|
||||
|
||||
刚接触 MCP,或者刚接触这个 SDK?从这里开始。这几页会带你从零开始,做出一个能用、经过测试的服务器:[安装 SDK](installation.md)、构建[第一个服务器](first-steps.md)、[把它接入真实的宿主](real-host.md),然后用内存客户端[测试它](testing.md)。
|
||||
|
||||
## 运行代码 {#run-the-code}
|
||||
|
||||
所有代码块都可以直接复制使用:它们都是完整、可运行的文件。
|
||||
|
||||
想跟着做,就把某个代码块粘贴到 `server.py` 里,再用 MCP Inspector 打开:
|
||||
|
||||
```console
|
||||
uv run mcp dev server.py
|
||||
```
|
||||
|
||||
**强烈建议**亲手写(或者复制)这些代码,改一改,然后在本地运行。只有在自己的编辑器里真正用上,才能体会到关键所在:要写的代码非常少,有自动补全,还没运行,类型检查就已经把错误找出来了。
|
||||
|
||||
## 不用靠猜 {#you-will-not-be-guessing}
|
||||
|
||||
这些文档中的每个示例,都是 SDK 自身仓库 [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) 目录下的完整文件;SDK 的测试套件会通过**内存客户端**把它们逐一运行一遍:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result.structured_content == {"result": 3}
|
||||
```
|
||||
|
||||
没有子进程,不占端口,也不经过任何传输。`Client(mcp)` 直接连到服务器对象上。
|
||||
|
||||
如果对 SDK 的某次改动弄坏了这些页面上的某个示例,CI 会在页面出问题之前先变红。这里读到的代码,就是实际运行的代码。
|
||||
|
||||
在[测试](testing.md)中你会亲手用到它;测试自己的服务器,用的也是这个方法。
|
||||
|
||||
## 接下来去哪里 {#where-to-go-next}
|
||||
|
||||
服务器跑起来之后,其余文档就是参考手册,而不是课程。每一页都自成一体,需要什么就直接跳过去看:
|
||||
|
||||
* 服务器对外暴露什么(工具、资源、提示词),见 **[服务器](../servers/index.md)**。
|
||||
* 注册的函数内部有什么可用,见 **[在处理函数内部](../handlers/index.md)**。
|
||||
* 怎样把它送到客户端面前(stdio、HTTP、现有的 FastAPI 应用),见 **[运行服务器](../run/index.md)**。
|
||||
* 构建另一端,也就是**使用** MCP 服务器的应用,见 **[客户端](../client/index.md)**。
|
||||
45
i18n/zh/pages/get-started/installation.md
Normal file
45
i18n/zh/pages/get-started/installation.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
translation:
|
||||
sections: [6e2f9bab94d5ed36, 8cf653388f69e28b, 6fd9ea2f65de0df6]
|
||||
tool: 1
|
||||
---
|
||||
# 安装 {#installation}
|
||||
|
||||
Python SDK 在 PyPI 上的包名是 [`mcp`](https://pypi.org/project/mcp/),需要 **Python 3.10+**。
|
||||
|
||||
本文档描述的是 **v2**,也就是当前的稳定版本系列:
|
||||
|
||||
=== "uv"
|
||||
|
||||
```bash
|
||||
uv add "mcp[cli]"
|
||||
```
|
||||
|
||||
=== "pip"
|
||||
|
||||
```bash
|
||||
pip install "mcp[cli]"
|
||||
```
|
||||
|
||||
!!! note "从 v1 迁移过来?"
|
||||
v2 是包含破坏性变更的主版本,**[迁移指南](../migration.md)** 涵盖了其中每一处。如果你的**包**依赖 `mcp` 且还没准备好迁移,请保留 `<2` 的版本上限(例如 `mcp>=1.28,<2`),这样在未锁定版本的情况下,依赖解析仍会停留在 1.x 系列。
|
||||
|
||||
## 安装了什么 {#what-gets-installed}
|
||||
|
||||
使用 SDK 并不需要了解这些,不过如果你好奇每个依赖是做什么的:
|
||||
|
||||
* `mcp-types`:所有协议类型(请求、结果、内容块)独立成一个包,版本与 SDK 同步发布。依赖 `mcp` 的代码通过 `mcp.types` 这个别名导入它(本文档里每一处 `from mcp.types import ...` 都是这样);只有在安装了 `mcp-types` 却没有安装 SDK 的项目里,才直接导入 `mcp_types`。
|
||||
* [`anyio`](https://anyio.readthedocs.io/):异步运行时。整个 SDK 都基于 anyio 编写,因此既能跑在 `asyncio` 上,也能跑在 `trio` 上。
|
||||
* [`pydantic`](https://docs.pydantic.dev/):每个 `mcp.types` 模型都构建在它之上,所有的模式生成和校验也由它完成。
|
||||
* [`httpx2`](https://pypi.org/project/httpx2/):支撑 Streamable HTTP 和 SSE **客户端**传输方式的 HTTP 客户端,内置对 server-sent events 的支持。
|
||||
* [`starlette`](https://www.starlette.io/)、[`uvicorn`](https://www.uvicorn.org/)、[`sse-starlette`](https://pypi.org/project/sse-starlette/) 和 [`python-multipart`](https://pypi.org/project/python-multipart/):HTTP **服务器端**传输方式。
|
||||
* [`jsonschema`](https://pypi.org/project/jsonschema/):对照工具声明的输出模式,校验工具的结构化输出。
|
||||
* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/):授权所需的 OAuth 令牌处理。
|
||||
* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/):仅包含轻量级的 API,所以除非你自己安装 OpenTelemetry SDK 和导出器,否则 SDK 的追踪中间件不会带来任何开销。
|
||||
* [`typing-extensions`](https://typing-extensions.readthedocs.io/) 和 [`typing-inspection`](https://pypi.org/project/typing-inspection/):在 Python 3.10 上提供现代的类型标注特性。
|
||||
* [`pywin32`](https://pypi.org/project/pywin32/):仅 Windows 需要,用于 `stdio` 子进程管理。
|
||||
|
||||
## 可选附加依赖 {#optional-extras}
|
||||
|
||||
* `mcp[cli]` 会额外安装 [`typer`](https://typer.tiangolo.com/) 和 [`python-dotenv`](https://pypi.org/project/python-dotenv/),供 `mcp` 命令行工具(`mcp dev`、`mcp run`、`mcp install`)使用。开发期间会用到它;部署后的服务器里未必需要。
|
||||
* `mcp[rich]` 会额外安装 [`rich`](https://rich.readthedocs.io/),让服务器日志更美观。
|
||||
168
i18n/zh/pages/get-started/real-host.md
Normal file
168
i18n/zh/pages/get-started/real-host.md
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
translation:
|
||||
sections: [3c4f2f06b4e978b6, 22520eecae3d1961, f4e1709db18d635a, 2eb57992049671d9, 1ba83e9af37cc1b4, 4822586344b08d9e, 1c93afef72478992, b6b448f9eddd51dc, fe55370fd931815b]
|
||||
tool: 1
|
||||
---
|
||||
# 连接到真实的宿主 {#connect-to-a-real-host}
|
||||
|
||||
**宿主** 是你的服务器最终所处的应用程序:Claude Desktop、Claude Code、IDE。用户与之对话的是宿主。在宿主内部,一个 MCP **客户端** 把你的服务器作为子进程启动,并通过该进程的 stdin 和 stdout 与它通信。
|
||||
|
||||
也就是说,连接到宿主只需要做一件事:告诉它 **启动服务器的命令**。本页的所有内容(两条 CLI 命令、三个 JSON 文件)都只是放置同一条命令的不同位置。
|
||||
|
||||
## 一个服务器,所有宿主 {#one-server-every-host}
|
||||
|
||||
```python title="server.py" hl_lines="3 33-34"
|
||||
--8<-- "docs_src/real_host/tutorial001.py"
|
||||
```
|
||||
|
||||
两个工具加一个资源,全在一个文件里。关于这个文件,有三点对下面每个宿主都很重要:
|
||||
|
||||
* 不带参数的 `mcp.run()` 启动的是 **stdio** 服务器:它会阻塞,从 stdin 读取协议消息,再把消息写到 stdout。本页每个宿主用的都是这种传输方式。宿主把你的文件作为子进程启动,并掌管这两个管道,所以连接从来都只是“把命令告诉它”这一件事。你永远不用选端口,也没有任何东西在端口上监听。
|
||||
* `run()` 放在 `if __name__ == "__main__":` 之下。下文的所有方式都是 **导入** 这个文件而不是执行它,所以不加这层保护的 `run()` 会在模块被任何东西加载的那一刻就启动服务器。
|
||||
* 服务器对象是一个名为 `mcp` 的模块级全局变量。这是 `mcp run` 要找的名字(`server` 和 `app` 也行)。如果起了别的名字,就得显式指定:`mcp run server.py:bookshop`。
|
||||
|
||||
这是本页最后一行 Python。从这里往下全是宿主配置。
|
||||
|
||||
## 启动命令 {#the-launch-command}
|
||||
|
||||
下面每个宿主拿到的都是同一条命令:
|
||||
|
||||
```bash
|
||||
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
|
||||
```
|
||||
|
||||
所有宿主共用一条命令,是因为 `uv run --with` 会当场把 SDK 解析进一个全新的环境:在任何目录下都能用,既不需要项目,也不需要激活虚拟环境。这一点在这里比在别处都更要紧,因为宿主是从 **它自己的** 工作目录、带着几乎为空的环境启动你的服务器,而不是从你的 shell。
|
||||
|
||||
它也是 `mcp install` 替你写进 Claude Desktop 配置的那条命令(见下文),所以手敲的和工具生成的是一致的,差别只在工具额外加上的精确版本锁定。
|
||||
|
||||
!!! tip "如果宿主找不到 `uv`"
|
||||
宿主启动你的服务器时只带一个极简的 `PATH`,`uv` 可能不在其中。把不带路径的 `uv` 换成 `which uv`(macOS/Linux)或 `where uv`(Windows)给出的绝对路径。`mcp install` 写入的正是这个。
|
||||
|
||||
!!! note "本页讲的是本地场景"
|
||||
这里的一切都是在宿主所在的那台机器上运行你的服务器:宿主通过 stdio 启动你的文件。对个人工具或单机工具来说,这样做完全合适。要把服务器交给 **没有** 你这个文件的人,给出去的是 **URL** 而不是命令:同一个 `mcp` 对象,通过 Streamable HTTP 提供服务。**[运行服务器](../run/index.md)** 用一张表讲清这个决策,**[部署与扩展](../run/deploy.md)** 则是从那里走到真实主机名的路线。
|
||||
|
||||
而且宿主不过是内置了 MCP 客户端的应用程序,所以你自己的 Python 也能扮演宿主的角色:**[客户端传输方式](../client/transports.md)** 用 `stdio_client(...)` 把同一个文件作为子进程启动,**[测试](testing.md)** 则一个进程都不起,直接在内存中连接它。
|
||||
|
||||
## Claude Desktop {#claude-desktop}
|
||||
|
||||
唯一一个 SDK 能替你配置的宿主:
|
||||
|
||||
```bash
|
||||
uv run mcp install server.py
|
||||
```
|
||||
|
||||
就这样。`mcp install` 导入该文件以读取服务器的名字,找到 Claude Desktop 的配置文件,然后把启动命令写进去。过程中它会把你的路径转换成绝对路径,省得你自己动手。
|
||||
|
||||
这里没有什么玄机。它写入的条目是这样的:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Bookshop": {
|
||||
"command": "/absolute/path/to/uv",
|
||||
"args": [
|
||||
"run",
|
||||
"--frozen",
|
||||
"--with",
|
||||
"mcp[cli]==2.0.0",
|
||||
"mcp",
|
||||
"run",
|
||||
"/absolute/path/to/server.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这就是上一节的启动命令,外加三样东西:`uv` 的绝对路径、`--frozen`(让 `uv` 永远不会改写它碰巧挨着的锁文件),以及对你已安装的 `mcp` 版本的精确锁定。它最终写进 `claude_desktop_config.json`,该文件位于:
|
||||
|
||||
* **macOS**:`~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
* **Windows**:`%APPDATA%\Claude\claude_desktop_config.json`
|
||||
|
||||
这个文件可以手写。`mcp install` 存在的意义,就是让你手写时不会犯那个经典错误(相对路径)。
|
||||
|
||||
完全退出 Claude Desktop(不只是关掉窗口),再重新打开。
|
||||
|
||||
!!! warning
|
||||
如果 Claude Desktop 的配置 **目录** 还不存在,`mcp install` 会失败并报 `Claude app not found`。安装 Claude Desktop 并运行一次:目录正是这一步创建的。
|
||||
|
||||
!!! tip
|
||||
Claude Desktop 在它自己的进程中启动你的服务器,所以那里没有你 shell 里的环境变量。`uv run mcp install server.py -v API_KEY=abc123`(或 `-f .env`)会把它们记到条目的 `env` 字段里。`--name` 用来覆盖条目名;默认取服务器的 `name`。
|
||||
|
||||
## Claude Code {#claude-code}
|
||||
|
||||
没有文件要编辑。用 `claude` CLI 注册服务器;`--` 之后的所有内容就是启动命令。
|
||||
|
||||
```bash
|
||||
claude mcp add bookshop -- uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
|
||||
```
|
||||
|
||||
在 Claude Code 会话中运行 `/mcp`,确认 `bookshop` 已连接,且它的工具已列出。
|
||||
|
||||
## Cursor {#cursor}
|
||||
|
||||
在项目根目录创建 `.cursor/mcp.json`。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bookshop": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同样的 `command` 加 `args`,放在 Claude Desktop 也在用的 `mcpServers` 键下。服务器会出现在 Cursor 的 MCP 设置里,两个工具都已列出。
|
||||
|
||||
## VS Code {#vs-code}
|
||||
|
||||
在项目根目录创建 `.vscode/mcp.json`。
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"bookshop": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "--with", "mcp[cli]", "mcp", "run", "/absolute/path/to/server.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
和 Cursor 的文件有两处不同,也仅此两处:外层键是 `servers` 而不是 `mcpServers`,而且每个条目都声明自己的 `type`。确认信任提示之后,在命令面板中执行 **MCP: List Servers**,会看到 `bookshop` 正在运行。
|
||||
|
||||
!!! note
|
||||
需要 VS Code 1.99 或更高版本,安装 **GitHub Copilot** 扩展并登录(Copilot Free 就够),而且 Copilot Chat 必须处于 **Agent** 模式,因为别的模式都不会调用工具。
|
||||
|
||||
## 服务器没有出现 {#it-doesnt-show-up}
|
||||
|
||||
在改动任何宿主配置之前,先自己运行一遍启动命令:
|
||||
|
||||
```bash
|
||||
uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py
|
||||
```
|
||||
|
||||
什么都不打印,也不返回。这种沉默是正确的:stdio 服务器正在等宿主先在 stdin 上开口(按 `Ctrl-C` 停止)。出现 traceback 或者立刻退出,那才是真正的 bug;现在可以直接读到它,而不用隔着宿主去猜。
|
||||
|
||||
一旦这条命令能停在那里等待,剩下的问题几乎总是下面三种之一:
|
||||
|
||||
* **相对路径。** 宿主从 **它自己的** 工作目录启动你的服务器,而不是你注册时所在的目录。在需要 `/absolute/path/to/server.py` 的地方写成了 `server.py`,是所有失败里最常见的一个。如果宿主连 `uv` 也找不到,那个路径同样必须是绝对路径。
|
||||
* **宿主还在跑旧配置。** 宿主在启动时读取配置。尤其是 Claude Desktop,必须 **完全退出**(不只是关掉窗口)再重新打开,对 `claude_desktop_config.json` 的修改才会生效。
|
||||
* **有东西在重定向窗口期之外写到了 stdout。** 在 stdio 上,stdout **就是** 协议。SDK 在提供服务期间会把已刷新的杂散输出重定向到 stderr,但在那之前就刷新到 stdout 的输出(包装脚本的回显、无缓冲进程里导入期间的 `print()`),或者直到解释器退出才排空的带缓冲 `print()`,都会递给宿主一条损坏的消息,宿主随即断开连接。用默认的 `logging` 配置记日志,它的 stderr handler 每条记录都会刷新;自定义 handler 同样必须避开 stdout。详见 **[日志](../handlers/logging.md)**。
|
||||
|
||||
Claude Desktop 为每个服务器各留一份日志:`mcp-server-<NAME>.log` 是你服务器的 stderr,和记录连接情况的 `mcp.log` 放在一起,macOS 上在 `~/Library/Logs/Claude` 下,Windows 上在 `%APPDATA%\Claude\logs` 下。
|
||||
|
||||
这三种之外的任何问题,去看 **[故障排查](../troubleshooting.md)**。
|
||||
|
||||
## 回顾 {#recap}
|
||||
|
||||
* **宿主**(Claude Desktop、IDE)运行一个 MCP 客户端,由它通过 stdio 把你的服务器作为子进程启动。连接就是给它一条启动命令。
|
||||
* 这条命令是 `uv run --with "mcp[cli]" mcp run /absolute/path/to/server.py`:无需激活 venv,在任何目录下都能用。
|
||||
* **Claude Desktop** 是唯一一个 `mcp install` 能替你配置的宿主。它把同一条命令(外加 `uv` 的绝对路径、`--frozen`,以及对已安装版本的精确锁定)写进 `claude_desktop_config.json`,你永远不必自己动手。
|
||||
* **Claude Code** 用 `claude mcp add bookshop -- <launch command>`。**Cursor** 用 `.cursor/mcp.json`,放在 `mcpServers` 下。**VS Code** 用 `.vscode/mcp.json`,放在 `servers` 下,每个条目带一个 `type`。
|
||||
* 处处使用绝对路径,改完配置后重启宿主,并且绝不让 SDK 以外的任何东西写入 stdout。
|
||||
|
||||
本页每个宿主都用同一条命令连接到了同一个文件。至于这个文件能 **暴露** 什么,就是这套文档余下的内容:**[工具](../servers/tools.md)**、**[资源](../servers/resources.md)**,以及 **[运行服务器](../run/index.md)** 中 stdio 之外的每一种传输方式。
|
||||
96
i18n/zh/pages/get-started/testing.md
Normal file
96
i18n/zh/pages/get-started/testing.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
---
|
||||
translation:
|
||||
sections: ['4926721070127497', c52a1de2b6b32f40, 2e410b412c25f314, 627195f7159e24ef]
|
||||
tool: 1
|
||||
---
|
||||
# 测试 {#testing}
|
||||
|
||||
Python SDK 提供了一个带**内存传输**的 `Client` 类:把服务器对象传给它,它就会直接连接上去。
|
||||
|
||||
不用子进程,不占端口,根本不走任何传输。思路和 FastAPI 的 `TestClient` 一样。
|
||||
|
||||
## 基本用法 {#basic-usage}
|
||||
|
||||
假设有一个简单的服务器,只有一个工具:
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/testing/tutorial001.py"
|
||||
```
|
||||
|
||||
要运行下面的测试,还需要两个额外的(开发)依赖项:
|
||||
|
||||
=== "uv"
|
||||
|
||||
```bash
|
||||
uv add --dev pytest inline-snapshot
|
||||
```
|
||||
|
||||
=== "pip"
|
||||
|
||||
```bash
|
||||
pip install pytest inline-snapshot
|
||||
```
|
||||
|
||||
!!! info
|
||||
本文档假设你已经熟悉 [`pytest`](https://docs.pytest.org/en/stable/)。
|
||||
|
||||
下面的测试用 [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) 在一行里对整个结果对象做断言。它会把测试的输出记录成你看到的 `snapshot(...)` 字面量。如果不想用它,去掉这行 import,像其他任何测试一样对关心的字段做断言(`result.content[0].text == "3"`)即可。
|
||||
|
||||
下面是测试:
|
||||
|
||||
```python title="test_server.py"
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp import Client
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend(): # (1)!
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(): # (2)!
|
||||
async with Client(mcp, raise_exceptions=True) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_add_tool(client: Client):
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
# Drop the server identity stamp in `_meta`; it is not what this test is about.
|
||||
result.meta = None
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(type="text", text="3")],
|
||||
structured_content={"result": 3},
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
1. 如果用的是 `trio`,就改为返回 `"trio"`。详见 [anyio 文档](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on)。
|
||||
2. 这个 fixture 产出一个已连接的客户端。每个接收 `client` 的测试,都会拿到一条连向同一服务器的全新内存连接。
|
||||
|
||||
这样就行了。接下来可以扩展测试,覆盖更多场景。
|
||||
|
||||
## 为什么用 `raise_exceptions=True`? {#why-raise_exceptionstrue}
|
||||
|
||||
可能出错的情况有两种,而这个标志只管其中一种。
|
||||
|
||||
**你的工具**内部抛出的异常不算协议失败。它会变成一个带 `is_error=True` 的普通结果,模型会读到其中的消息。`raise_exceptions` 不会改变这一点:不管有没有它,`call_tool` 返回的都是同一个 `is_error=True` 结果。有一整页专门讲这个:**[处理错误](../servers/handling-errors.md)**。
|
||||
|
||||
工具函数体**之外**的失败则不同。在 `Client(mcp)` 提供的这条连接上,服务器会先把它脱敏成一条笼统的 `"Internal server error"`,客户端才会看到。意外崩溃的细节绝不应该泄露给远程调用方。但在测试里,这恰恰是你**不**想要的,也正是 `raise_exceptions=True` 所改变的:测试看到的是真实的消息,而不是脱敏后的那条。
|
||||
|
||||
测试里就让它开着。它在生产代码中没有意义。
|
||||
|
||||
## 默认在进程内 {#in-process-by-default}
|
||||
|
||||
!!! note
|
||||
`Client(mcp)` 在进程内连接,默认**不区分协议时代**:它会先探测服务器,再选择合适的协议路径。如果测试要验证旧版(legacy)特有的语义(采样(sampling)或征询(elicitation)的推送、`message_handler`),就固定使用 `mode="legacy"`,并在这种情况下去掉 `raise_exceptions=True`:旧版连接本来就不做脱敏,而这个标志会让失败在服务器任务内部重新抛出,而不是抛到你的测试里。
|
||||
|
||||
也正是因为这一行,本文档才敢保证其中的示例都能跑通:每个示例文件都会在 SDK 自己的测试套件里跑一遍,而且几乎全都正是通过这个客户端。你用的,就是 SDK 用来测试自己的同一个工具。
|
||||
|
||||
现在你有了一个可用且经过测试的服务器。要把它接入真实的应用(Claude Desktop、IDE),见 **[连接到真实宿主](real-host.md)**;以其他任何方式对外提供它,见 **[运行服务器](../run/index.md)**。
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue