docs: publish translated docs in twelve languages and the tool that maintains them (#3280)

This commit is contained in:
Max 2026-08-14 17:07:34 +01:00 • committed by GitHub
commit 52ad0a8876
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 89737 additions and 54 deletions

View 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
```

View 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()]`,就知道这两者可能触碰过的**一切**。

View 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)** 各有自己的顶层页面。
如果不确定自己需不需要这一节,那就是不需要。

View 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)**。

View 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)** 决定的则是请求究竟能不能运行。

View 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)**。