MCP 动态工具注册与发现详解
MCP 动态工具注册与发现:客户端如何通过 tools/list、分页和 list_changed 通知发现工具,以及如何在 TypeScript 和 Python SDK 中于运行时添加或移除工具。
MCP 客户端通过对每个已连接的服务器调用 tools/list 来发现工具,并沿着 nextCursor 一直取完所有分页。当服务器的工具集在运行时发生变化,声明了 tools.listChanged 的服务器会发送 notifications/tools/list_changed,客户端随后再次调用 tools/list。所谓动态工具注册,就是这个循环的服务器端:添加、移除、启用或更新一个工具,然后发送通知。
机制就这么多。真正容易踩坑的是当前规范版本(2026-07-28)中的新规则、随 v2 TypeScript 和 Python SDK 一起变化的 API,以及各个客户端处理通知的方式不同。本文三者都会讲到。
工具发现是如何工作的
发现过程分四步,全部定义在 MCP 规范的 Tools 章节中:
- 能力声明。 暴露工具的服务器声明
tools能力。设置listChanged: true表示列表变化时会通知客户端。 - 列出。 客户端发送
tools/list。每个工具返回name、可选的title、description、inputSchema(JSON Schema)、可选的outputSchema、annotations和icons。 - 分页。 如果响应中带有
nextCursor,客户端带上这个游标再次发送tools/list,直到不再返回游标为止。 - 保持新鲜。 客户端在收到
notifications/tools/list_changed,或缓存结果过期时重新获取。
当前版本中的 tools/list 响应如下:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"tools": [
{
"name": "get_weather",
"description": "Get current weather information for a location",
"inputSchema": {
"type": "object",
"properties": { "location": { "type": "string" } },
"required": ["location"]
}
}
],
"nextCursor": "next-page-cursor",
"ttlMs": 300000,
"cacheScope": "public"
}
}
分页规则
分页规范很短,但很严格:
- 游标是不透明的。客户端不得解析或修改它。
- 每页大小由服务器决定;客户端不得假设固定页大小。
- 没有
nextCursor才表示结束。空字符串是合法的游标,不代表结束。 - 无效游标应返回错误
-32602(Invalid params)。
一个最简的手动循环:
let cursor: string | undefined;
const all = [];
do {
const page = await client.listTools(
cursor === undefined ? undefined : { cursor }
);
all.push(...page.tools);
cursor = page.nextCursor;
} while (cursor !== undefined);
实际上你很少需要自己写:v2 TypeScript 客户端在不传游标时,listTools() 会自动聚合所有分页(默认通过 listMaxPages 最多取 64 页)。
缓存提示(2026-07-28 新增)
当前版本新增了缓存机制。服务器必须在 tools/list 结果中包含 ttlMs 和 cacheScope:
ttlMs——客户端可以将列表视为新鲜的时长。它是新鲜度提示,不是轮询间隔。cacheScope: "public"——对所有用户都相同;共享网关可以缓存。cacheScope: "private"——列表取决于请求者(例如按 OAuth scopes 过滤),不得在不同授权上下文之间共享。
TTL 和 listChanged 是配合使用的:即使 TTL 尚未到期,收到通知也会立即使缓存的列表失效。无法推送通知的服务器,也可以用较短的 TTL 让客户端保持相对及时的更新。
变更通知:旧的会话 vs. 新的订阅流
list_changed 通知如何到达客户端,取决于协商出的协议版本:
| 2025-11-25 及更早 | 2026-07-28(当前) | |
|---|---|---|
| 投递方式 | 初始化后服务器可随时在会话上发送 | 只通过客户端以 toolsListChanged: true 打开的 subscriptions/listen 流发送 |
| 服务器确认 | — | notifications/subscriptions/acknowledged 列出它会发送的内容 |
| 工具列表能否按连接变化? | 实践中很常见(会话状态) | 不能——不得按连接变化,也不得因同一连接上的其他请求而变化 |
| 能否按调用者变化? | 能 | 能,依据请求所携带的授权(例如已授予的 scopes) |
第三行对"动态工具"影响最大。在 2026-07-28 中,MCP 没有协议层面的会话,所以过去那种"先调用 login,然后这个连接上就出现新工具"的模式已经不再允许。工具可以全局变化(插件加载、功能开关切换、上游 API 新增端点),或者因调用者的凭证不同而不同。对于需要跨调用保持的工作流状态,规范建议由一个工具返回显式的 handle,再传给下一个工具。
规范还要求服务器以确定的顺序返回工具,这样客户端缓存能保持有效,也能提高 LLM 提示词缓存的命中率。
在运行时注册和注销工具
TypeScript SDK(v2)
v2 TypeScript SDK 以 @modelcontextprotocol/server 发布(取代 @modelcontextprotocol/sdk)。registerTool 返回一个 handle,带有 enable()、disable()、update() 和 remove()。根据 SDK 通知指南,通过 handle 进行的每一次修改都会自动发送 list_changed,而且只要注册了工具,McpServer 就会声明 tools.listChanged: true。
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
serveStdio(() => {
const server = new McpServer({ name: 'reports', version: '1.0.0' });
const exportCsv = server.registerTool(
'export_csv',
{
description: 'Export a report as CSV',
inputSchema: z.object({ reportId: z.string() }),
},
async ({ reportId }) => ({
content: [{ type: 'text', text: `exported ${reportId}` }],
})
);
// 在功能开启前不出现在 tools/list 中
exportCsv.disable();
// 你自己的功能开关钩子(不属于 SDK)
onFeatureFlag('exports', (on) =>
on ? exportCsv.enable() : exportCsv.disable()
);
return server;
});
被禁用的工具会从 tools/list 中消失,调用它会以 Invalid params 错误失败。update({ description, paramsSchema, callback, ... }) 可以原地修改工具,remove() 则将其删除。如果变化来自 SDK 无法感知的地方,自己调用 server.sendToolListChanged()。
对于用 createMcpHandler 构建的无状态 HTTP 部署,McpServer 实例是按请求创建的,所以应通过 handler 发布:handler.notify.toolsChanged() 会推送到所有请求了工具变更的 subscriptions/listen 流。
Python SDK(v2)
在 mcp 2.x 中,FastMCP 已更名为 MCPServer(from mcp.server.mcpserver import MCPServer)。它提供 add_tool() 和 remove_tool(),但与 TypeScript 的 handle 不同,它们不会自动发送通知——需要你自己发布事件。在工具内部,使用 await ctx.notify_tools_changed()。在请求之外(后台任务、文件监听器),传入你自己的订阅总线并向其发布:
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
bus = InMemorySubscriptionBus()
mcp = MCPServer("reports", subscriptions=bus)
def export_csv(report_id: str) -> str:
"""Export a report as CSV."""
return f"exported {report_id}"
async def set_exports_enabled(enabled: bool) -> None:
if enabled:
mcp.add_tool(export_csv)
else:
mcp.remove_tool("export_csv")
await bus.publish(ToolsListChanged())
if __name__ == "__main__":
mcp.run()
ctx.notify_tools_changed() 和总线都会推送到 subscriptions/listen 流,这正是 2026-07-28 客户端的监听方式。对于使用旧版本协议的客户端,会话级的 await ctx.session.send_tool_list_changed() 会发送旧式通知。多副本部署时,可以基于 Redis、NATS 等实现 SubscriptionBus 协议,让每个副本上的流都能收到事件。
独立的 FastMCP 框架有自己的 API(mcp.disable(names=...)、mcp.enable(tags=..., only=True)、基于标签的可见性控制),其文档说明在活动请求中调用时会自动发送 list_changed 通知。请以你安装版本的文档为准。
编写客户端一侧
使用 v2 TypeScript 客户端时,注册一次处理函数,SDK 就会替你重新获取(默认有 300 毫秒防抖):
import { Client } from '@modelcontextprotocol/client';
const client = new Client(
{ name: 'my-host', version: '1.0.0' },
{
listChanged: {
tools: {
onChanged: (error, tools) => {
if (error) return console.error('Refresh failed:', error);
console.log('Tools refreshed:', tools?.length);
},
},
},
}
);
只有当服务器声明了 tools.listChanged 时,这个处理函数才会生效。如果你要聚合多个服务器的工具,给工具名加上服务器标识前缀:规范提醒,两个服务器可能都暴露一个 search 工具,而 serverInfo.name 并不保证唯一。
客户端支持的注意事项
规范说服务器应当发送通知,但并不强制宿主以同样的方式响应。在围绕动态工具做设计之前,先确认目标宿主的实际行为:
- Claude Code 的文档说明:在交互式会话中,收到
list_changed时会重新获取工具、提示词和资源;在-p(无头)模式和 Agent SDK 中只刷新工具列表。在 2026-07-28 运行时下,它会保持一个通知流,并限制对反复关闭的流的重连频率,因此在 serverless 宿主上,更新可能会滞后,直到你在/mcp中重新连接。 - 其他宿主因版本和协议版本而异。有些只在连接时获取工具。把运行时变化当作一种优化,保证服务器在使用初始列表时也能正常工作。
- 上下文预算才是真正的限制。 会延迟加载工具定义的宿主(例如 Claude Code 的工具搜索)让大型工具集的成本更低,往往根本不需要动态隐藏工具。
当运行时注册并不合适时,可以考虑:启动时选定的静态工具集(GitHub MCP Server 就使用 --toolsets 参数)、把一个大服务器拆成几个专注的小服务器,或者用网关把多个服务器聚合到一个端点后面(参见我们的 Docker MCP Gateway 指南)。
注册中心:发现的是服务器,不是工具
"发现"一词也常被用来指寻找服务器。那是另一层:
- 官方 MCP Registry(预览阶段)在经过验证的命名空间下存储
server.json元数据——名称、包或远程 URL、安装说明。它主要供下游聚合器和市场使用,而不是让宿主应用直接调用。 - 像 MCP.so 这样的目录在其之上提供筛选、分类和搜索,让人们在发生任何
tools/list之前就能找到服务器。 - 在协议内部,
server/discover(2026-07-28 新增)会在其他请求之前返回服务器支持的版本、能力和身份。
所以完整链路是:注册中心或目录找到服务器,server/discover 检查兼容性,tools/list 找到它的工具,list_changed 让工具列表保持最新。
上线前先测试
用 MCP Inspector 验证动态行为:连接后切换你的功能开关,在 Protocol 标签页中观察 notifications/tools/list_changed 以及随后的 tools/list。在 Server Settings 中打开 Fetch Lists One Page at a Time,即可逐页检查分页。另外,变化的工具列表本身也是一个安全面——工具描述可能在用户批准之后被修改——所以也请阅读我们的 MCP 安全最佳实践。
下一步
- 按分类浏览 MCP 服务器,看看真实服务器如何组织大型工具集,例如 Playwright MCP 和 Filesystem 服务器。
- 确认你需要支持哪些 MCP 客户端,然后逐一测试
list_changed。 - 在 MCP.so 目录中找到你的下一个服务器。
常见问题
MCP 客户端如何发现工具?
- 客户端对每个已连接的服务器调用 tools/list,并沿着 nextCursor 取完所有分页。当服务器发送 notifications/tools/list_changed,或缓存列表的 ttlMs 过期时,客户端会重新获取。
MCP 服务器能在运行时添加或移除工具吗?
- 可以。只要服务器声明了 tools.listChanged,就可以修改工具集并发送 notifications/tools/list_changed。在 TypeScript SDK 中,registerTool 返回的 handle 的 enable、disable、update 和 remove 方法会自动发送通知;在 Python SDK 中需要自己发布。
MCP 服务器能向不同用户展示不同的工具吗?
- 在 2026-07-28 规范下,工具列表可以根据请求携带的授权(例如已授予的 OAuth scopes)而不同,但不得按连接变化,也不得作为其他请求的副作用而变化。这类列表应标记为 cacheScope 「private」。
所有 MCP 客户端都支持 list_changed 通知吗?
- 不是。支持情况因宿主、版本和协议版本而异,有些客户端只在连接时获取工具。Claude Code 的文档说明会在收到 list_changed 时重新获取,但你仍应逐一测试目标客户端,并确保服务器在使用初始工具列表时也能正常工作。