MCP 协议设计与实现
第5章 Tool:让 Agent 调用世界
第5章 Tool:让 Agent 调用世界
“The limits of my tools are the limits of my world.” ——改编自维特根斯坦
“工具的边界就是 Agent 能力的边界。没有 Tool 的 LLM 是一个永远在说话却从未动手的咨询师;有了 Tool 的 Agent 才是真正能推动事情发生的执行者。”
本章要点
- 理解 Tool 在三原语中的”动作性”定位:model-controlled 的执行通道
- 掌握 Tool 的完整协议结构:name、description、inputSchema、outputSchema、annotations
- 理解 Tool 的三阶段生命周期:发现、调用、变更通知
- 对比 TypeScript SDK 的
registerTool与 Python SDK 的@tool装饰器 - 掌握 Tool Annotations 四大 hint 对 Agent 安全决策的意义
- 理解协议层错误 vs 业务层错误的双层设计及其对自我修正的关键作用
- 与 OpenAI Function Calling、LangChain BaseTool 对比,看清 MCP 的设计定位
- 掌握 6 条 Tool 设计原则与 7 种常见反模式
5.1 Tool 在 MCP 中的定位
5.1.1 从”会说话的 LLM”到”会做事的 Agent”
2023 年之前,大多数人对 LLM 的印象是”一个会说话的百科全书”——它能回答问题,但只能回答。它不能帮你把邮件发出去,不能查看你的日历,不能运行一段代码验证它的答案。这种无力感有一个经典比喻:
“LLM 就像一个绑在椅子上的天才——它知道所有答案,但它的手被绑着。”
Tool 就是解绑这双手的协议。它让 LLM 从”输出文本”升级到”发起动作”:输出一段参数化的调用意图,由协议层执行到真实世界,再把结果塞回对话。
5.1.2 三原语中的 Tool
如果说 Resource 让大模型拥有了”读”的能力,那么 Tool 就是赋予大模型”做”的能力。一个只能阅读文件的 Agent 是被动的信息消费者,而一个能够调用数据库查询、发送 HTTP 请求、操作文件系统的 Agent,才真正具备了改变世界的能力。
Tool 是 MCP 协议三大原语中最具”动作性”的一个。在协议设计中,它被明确定义为 model-controlled——由大模型自主决定何时调用、调用哪个工具、传入什么参数。这与 Resource 的 application-controlled、Prompt 的 user-controlled 形成了清晰的控制权分层。理解 Tool 的协议设计与实现机制,是构建可靠 Agent 系统的关键一步。
5.1.3 Tool 调用的信任链条
在 MCP 的架构中,Tool 代表的是 Server 端暴露给大模型的可调用函数。当用户向 Agent 提出一个需要外部操作的需求时(比如”查一下北京今天的天气”),大模型会自主分析上下文,从已注册的 Tool 列表中选择合适的工具,构造参数并发起调用。
这个过程中有一个关键的信任链条:
sequenceDiagram
participant User as 用户
participant LLM as 大模型
participant Client as MCP Client
participant Server as MCP Server
participant World as 外部系统
User->>LLM: "查一下北京天气"
Note over LLM: 分析意图,选择 Tool
LLM->>Client: 决定调用 get_weather(location="北京")
Note over Client: 安全检查 / 用户确认
Client->>Server: tools/call {name, arguments}
Server->>World: 调用天气 API
World-->>Server: 返回天气数据
Server-->>Client: CallToolResult {content}
Client->>LLM: 将结果注入上下文
LLM->>User: "北京今天 25°C,晴转多云"
在这个流程中,有几个值得注意的设计原则:
- 大模型做选择,Client 做守门。大模型决定调用哪个 Tool、传什么参数,但 Client 应该(SHOULD)在实际发送
tools/call之前向用户展示确认提示,确保 human-in-the-loop。 - Server 做执行,协议做规范。Server 负责实际执行工具逻辑并返回结果,但返回的格式严格遵循协议定义的
CallToolResult结构。 - 工具是声明式的。每个 Tool 通过 JSON Schema 声明自己接受什么参数,大模型据此构造合法的调用请求。
这三层分工,构成了 Tool 作为”意图—守门—执行”三级流水线的完整闭环。
5.2 Tool 的定义结构
5.2.1 六个字段
一个 MCP Tool 的完整定义包含以下字段:
| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
name | string | 是 | 工具的唯一标识符,1-128 字符 |
title | string | 否 | 人类可读的显示名称 |
description | string | 否 | 功能描述,帮助大模型理解何时使用 |
inputSchema | JSON Schema | 是 | 输入参数的 JSON Schema 定义 |
outputSchema | JSON Schema | 否 | 输出结构的 JSON Schema 定义 |
annotations | ToolAnnotations | 否 | 行为标注(只读、破坏性等) |
工具名称的命名规范值得特别关注。协议规定名称应仅包含 A-Z、a-z、0-9、_、-、. 这些字符,区分大小写,不应包含空格或特殊字符。这是因为工具名称会直接出现在 JSON-RPC 的请求参数中,过于复杂的名称容易导致解析问题。合法的例子包括 getUser、DATA_EXPORT_v2、admin.tools.list。
5.2.2 一个典型 Tool 定义
{
"name": "get_weather",
"title": "天气查询",
"description": "查询指定城市的当前天气信息",
"inputSchema": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "城市名称或邮政编码"
}
},
"required": ["location"]
},
"annotations": {
"readOnlyHint": true,
"openWorldHint": true
}
}
inputSchema 必须是一个合法的 JSON Schema 对象,不能为 null。对于没有参数的工具,推荐使用 { "type": "object", "additionalProperties": false },明确表示只接受空对象。默认遵循 JSON Schema 2020-12 标准,也可以通过 $schema 字段显式指定其他版本。
5.2.3 “description” 的隐藏分量
看似不起眼的 description 字段,其实是 Tool 定义中最重要的字段之一——因为它直接决定了大模型是否能”选对”这个工具。
看两个对比:
{ "description": "查询天气" }
{
"description": "查询指定城市的**当前**(今天)天气。不支持历史天气或未来预报。支持中国大陆、香港、台湾、海外主要城市。参数 location 支持城市名(如'北京')或邮政编码(如'100000')。"
}
一个只有 4 个字,一个有 80 字并明确了边界、单位、支持范围。在 LLM 面对一组相似工具(get_weather、get_weather_forecast、get_historical_weather)时,description 的精确度决定了选择的正确率。真实项目里应把这件事做成工具选择评测:同一组任务、同一组候选工具、只替换 description,再比较模型是否选中正确工具,而不是脱离任务集套用固定百分比。
口诀:name 是给调度器看的,description 是给 LLM 看的。前者要简,后者要富。
5.3 Tool 的生命周期
Tool 的完整生命周期包括三个阶段:发现、调用、变更通知。
stateDiagram-v2
[*] --> 能力协商: initialize
能力协商 --> 工具发现: Server 声明 tools 能力
工具发现 --> 等待调用: Client 获取 Tool 列表
等待调用 --> 执行中: LLM 选择并调用 Tool
执行中 --> 返回结果: Server 执行完成
返回结果 --> 等待调用: 结果注入 LLM 上下文
等待调用 --> 工具发现: tools/list_changed 通知
note right of 能力协商: Server 在 capabilities 中<br/>声明 tools.listChanged
note right of 执行中: Server 验证输入<br/>执行业务逻辑
5.3.1 发现阶段:tools/list
Client 通过发送 tools/list 请求来发现 Server 上注册的所有工具。这个请求支持分页,通过 cursor 参数实现大量工具的分批获取:
// 请求
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }
// 响应
{
"jsonrpc": "2.0", "id": 1,
"result": {
"tools": [
{
"name": "get_weather",
"description": "查询天气信息",
"inputSchema": { "type": "object", "properties": { "location": { "type": "string" } }, "required": ["location"] }
}
],
"nextCursor": "page2"
}
}
5.3.2 调用阶段:tools/call
当大模型决定调用某个工具时,Client 发送 tools/call 请求,包含工具名和参数:
// 请求
{
"jsonrpc": "2.0", "id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "北京" }
}
}
// 成功响应
{
"jsonrpc": "2.0", "id": 2,
"result": {
"content": [
{ "type": "text", "text": "北京当前天气:25°C,晴转多云,湿度 45%" }
],
"isError": false
}
}
5.3.3 变更通知:tools/list_changed
当 Server 动态增加或移除了工具时,如果在初始化时声明了 listChanged: true 能力,就应该发送通知让 Client 重新拉取工具列表:
{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }
这个机制使得工具集可以是动态的——比如一个插件系统中,用户安装新插件后,对应的工具就会被注册并通过通知告知 Client。
5.3.4 工具集规模的”金发姑娘区”
一个实际问题:一个 Server 应该暴露多少个 Tool 才合适?
经验规律(来自 Anthropic 和社区的调研):
| Tool 数量 | LLM 表现 | 典型问题 |
|---|---|---|
| 1-5 个 | 极佳 | 功能覆盖有限 |
| 6-15 个 | 优秀 | 最常见的”sweet spot” |
| 16-30 个 | 良好 | 需要精心设计 name/description |
| 31-50 个 | 下降 | 选择困难,建议分组 |
| 50+ | 显著下降 | 必须配合工具搜索机制 |
这组数字不是硬性规则,但揭示了一个认知心理学般的现象:LLM 的”工具选择空间”是有实际上限的,和人类的”选择悖论”类似。如果你的 Server 不得不暴露 50+ 个 Tool,应该考虑:
- 合并近似工具——
search_user、search_product、search_order合并为search(entity, query) - 层次化组织——暴露
list_tools元工具,按需加载具体工具 - 拆分 Server——按领域拆成多个小 Server
5.4 SDK 中的 Tool 注册机制
5.4.1 TypeScript SDK:registerTool 方法
TypeScript SDK 中,McpServer 类提供了 registerTool 方法来注册工具。让我们看一个完整的注册示例:
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
const server = new McpServer({ name: 'weather-server', version: '1.0.0' });
server.registerTool(
'get_weather',
{
title: '天气查询',
description: '查询指定城市的当前天气信息',
inputSchema: z.object({
location: z.string().describe('城市名称或邮政编码')
}),
annotations: {
readOnlyHint: true,
openWorldHint: true
}
},
async ({ location }) => {
const weather = await fetchWeather(location);
return {
content: [{ type: 'text', text: `${location}:${weather.temp}°C` }]
};
}
);
在 SDK 内部,registerTool 调用了私有方法 _createRegisteredTool,这个方法完成了几个关键步骤:
- 名称校验:调用
validateAndWarnToolName(name)检查名称是否符合规范。 - Schema 转换:通过
standardSchemaToJsonSchema()将 Zod schema 等标准 schema 格式转换为 JSON Schema。 - 注册请求处理器:首次注册工具时,调用
setToolRequestHandlers()为tools/list和tools/call设置处理逻辑。 - 发送变更通知:调用
sendToolListChanged()通知已连接的 Client。
注册后的工具对象 RegisteredTool 还提供了动态管理能力——enable()、disable()、remove()、update() 方法让工具可以在运行时被启用、禁用、移除或更新配置。
5.4.2 Python SDK:@tool 装饰器
Python SDK 采用了更符合 Python 风格的装饰器模式:
from mcp.server.mcpserver.server import MCPServer
from mcp.types import ToolAnnotations
server = MCPServer(name="weather-server")
@server.tool(
description="查询指定城市的当前天气信息",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True
)
)
async def get_weather(location: str) -> str:
"""查询天气信息"""
weather = await fetch_weather(location)
return f"{location}:{weather['temp']}°C"
Python SDK 的 @tool 装饰器内部做了什么?从源码可以看到完整的流程:
- 函数元数据提取:调用
func_metadata(fn)解析函数签名,自动生成 Pydantic 模型作为参数校验的基础。 - JSON Schema 生成:通过
model_json_schema()从 Pydantic 模型自动生成 JSON Schema,作为工具的inputSchema。 - Context 注入检测:通过
find_context_parameter(fn)扫描函数签名,如果发现类型标注为Context的参数,就标记为 context 注入点,在调用时自动注入上下文对象。 - 工具注册:通过
ToolManager.add_tool()将构造好的Tool对象注册到管理器中。
Python SDK 中一个巧妙的设计是 Context 注入。开发者只需在函数签名中添加一个 ctx: Context 参数,SDK 就会自动将 MCP 上下文注入,无需额外配置:
@server.tool()
async def search_and_log(query: str, ctx: Context) -> str:
await ctx.info(f"正在搜索:{query}")
await ctx.report_progress(50, 100)
result = await do_search(query)
return result
5.4.3 两套 SDK 的设计对比
graph TB
subgraph TypeScript_SDK["TypeScript SDK"]
TS_REG["registerTool(name, config, callback)"]
TS_VAL["validateAndWarnToolName()"]
TS_SCH["standardSchemaToJsonSchema()"]
TS_HDL["setToolRequestHandlers()"]
TS_NTF["sendToolListChanged()"]
TS_REG --> TS_VAL
TS_REG --> TS_SCH
TS_REG --> TS_HDL
TS_REG --> TS_NTF
end
subgraph Python_SDK["Python SDK"]
PY_DEC["@server.tool() 装饰器"]
PY_META["func_metadata() 提取签名"]
PY_CTX["find_context_parameter()"]
PY_PYDANTIC["Pydantic model_json_schema()"]
PY_MGR["ToolManager.add_tool()"]
PY_DEC --> PY_META
PY_DEC --> PY_CTX
PY_META --> PY_PYDANTIC
PY_DEC --> PY_MGR
end
style TypeScript_SDK fill:#1a1a2e,stroke:#e94560,color:#eee
style Python_SDK fill:#1a1a2e,stroke:#0f3460,color:#eee
两者的核心差异在于 Schema 的来源:TypeScript SDK 依赖开发者显式传入 Zod 等 Standard Schema 格式;Python SDK 则利用 Python 的类型标注 + Pydantic 自动推导。最终两者都会将 Schema 转换为标准的 JSON Schema 格式注册到协议层。
5.5 输入校验机制
5.5.1 SDK 层面的校验
输入校验是 Tool 安全执行的第一道防线。SDK 在收到 tools/call 请求后、执行实际处理函数之前,会对 arguments 进行 Schema 校验。
TypeScript SDK 的校验逻辑在 validateToolInput 方法中:
private async validateToolInput(tool, args, toolName) {
if (!tool.inputSchema) {
return undefined; // 无 schema 则跳过校验
}
const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {});
if (!parseResult.success) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}`
);
}
return parseResult.data;
}
Python SDK 的校验发生在 Tool.run() 方法中,通过 call_fn_with_arg_validation 完成:
async def run(self, arguments, context, convert_result=False):
try:
result = await self.fn_metadata.call_fn_with_arg_validation(
self.fn, self.is_async, arguments,
{self.context_kwarg: context} if self.context_kwarg else None,
)
return result
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e
Python SDK 利用 Pydantic 的 model_validate 进行校验,这意味着不仅能检查类型,还能执行自定义验证器(validator)。如果参数校验失败,两个 SDK 都会抛出带有 InvalidParams 错误码的 ProtocolError,最终以 JSON-RPC 错误响应的形式返回给 Client。
5.5.2 业务层校验的”职责分界线”
SDK 只做类型级别的校验(字符串就是字符串、数字不超出范围),业务级别的校验仍是 Server 开发者的责任:
| 校验类型 | 谁做 | 例子 |
|---|---|---|
| 类型校验 | SDK 自动 | location 是 string 不是 number |
| 结构校验 | SDK 自动 | required 字段不能缺 |
| 语义校验 | Server 代码 | location 是否是存在的城市 |
| 权限校验 | Server 代码 | 当前用户是否能操作该资源 |
| 业务约束 | Server 代码 | 余额不足时拒绝转账 |
经典错误:误以为 SDK 校验过的数据就是”可信”的,跳过业务校验。实际上,SDK 只保证”格式对了”,不保证”语义对了”。
5.6 Tool Annotations:Agent 安全的元数据体系
5.6.1 四个 hint
Tool Annotations 是 MCP 协议中一个精心设计的安全机制。它为每个工具提供了行为层面的元数据标注,让 Client 和 Agent 框架能在调用工具之前就了解其潜在影响。
| 标注字段 | 默认值 | 含义 | 典型场景 |
|---|---|---|---|
readOnlyHint | false | 工具是否不修改环境 | 查询类:数据库 SELECT、API GET |
destructiveHint | true | 工具是否可能执行破坏性操作 | 删除数据、覆盖文件、发送不可撤回消息 |
idempotentHint | false | 相同参数重复调用是否无副作用 | PUT 操作、幂等更新 |
openWorldHint | true | 工具是否与开放世界交互 | Web 搜索(开放)vs 内存工具(封闭) |
5.6.2 三大策略场景
这些标注为什么对 Agent 安全如此重要?考虑以下场景:
场景一:自动重试策略。当一个 Tool 调用因网络超时失败时,Agent 是否应该自动重试?如果 idempotentHint: true,重试是安全的;如果 idempotentHint: false 且 destructiveHint: true(比如”发送邮件”工具),盲目重试可能导致重复发送。
场景二:确认提示策略。一个标记了 readOnlyHint: true 的工具(如数据库查询)可以在无需用户确认的情况下自动执行;而标记了 destructiveHint: true 的工具(如删除文件)则应该弹出确认对话框。
场景三:并行执行策略。多个 readOnlyHint: true 的工具可以安全地并行调用以提升响应速度;而 destructiveHint: true 的工具通常应该串行执行,避免竞态条件。
5.6.3 “Hint 不是 Guarantee”
协议特别强调:这些标注只是”提示”(hint),不是”保证”。来自不受信任的 Server 的标注不应被盲目采信。一个恶意 Server 完全可以将一个破坏性工具标记为 readOnlyHint: true 来绕过确认提示。
正确的信任边界:
| Server 来源 | 标注信任度 | 做法 |
|---|---|---|
| 官方/签名 Server | 高 | 直接按 hint 决策 |
| 企业内部 Server | 中 | 按 hint 决策 + 关键操作二次确认 |
| 第三方开源 Server | 低 | hint 仅作参考,所有写操作强制用户确认 |
| 未知 Server | 极低 | 禁止,或所有调用都需确认 |
5.7 错误处理的双层设计
5.7.1 协议层 vs 业务层
MCP Tool 的错误处理分为两个层次,这个设计深刻影响了 Agent 的错误恢复策略。
协议层错误(Protocol Errors) 以标准 JSON-RPC error 形式返回,表示请求本身有问题:
{
"jsonrpc": "2.0", "id": 3,
"error": {
"code": -32602,
"message": "Unknown tool: invalid_tool_name"
}
}
典型原因包括:调用了不存在的工具、请求格式不合法、Server 内部错误。这类错误通常意味着大模型”选错了工具”或者”构造了错误的请求”,重试价值有限。
业务层错误(Tool Execution Errors) 以正常的 CallToolResult 形式返回,但 isError 字段为 true:
{
"jsonrpc": "2.0", "id": 4,
"result": {
"content": [
{ "type": "text", "text": "日期格式错误:请使用 YYYY-MM-DD 格式" }
],
"isError": true
}
}
5.7.2 自我修正的关键原理
这类错误的关键特征是包含可操作的反馈信息。大模型读到”日期格式错误”后可以自行修正参数格式重新调用,这构成了 Agent 的自我纠错循环。
| 错误类型 | 是否注入 LLM | 修正价值 | 推荐处理 |
|---|---|---|---|
| 协议层(请求格式错误) | MAY 注入 | 低 | Client 可直接报错 |
| 业务层(语义错误) | SHOULD 注入 | 高 | 必须让 LLM 有机会修正 |
协议规定 Client 应该(SHOULD)将业务层错误传递给大模型以启用自我修正;对于协议层错误,Client 可以(MAY)传递给大模型,但恢复成功率通常较低。
5.7.3 SDK 的自动包装
在 TypeScript SDK 中,这种双层设计体现得很清晰。tools/call 处理器中,如果工具执行抛出异常(非 ProtocolError),SDK 会自动将其包装为 isError: true 的业务层错误:
// TypeScript SDK 内部实现
try {
const args = await this.validateToolInput(tool, request.params.arguments, name);
const result = await this.executeToolHandler(tool, args, ctx);
return result;
} catch (error) {
if (error instanceof ProtocolError) {
throw error; // 协议层错误直接抛出
}
// 其他异常包装为业务层错误
return {
content: [{ type: 'text', text: error.message }],
isError: true
};
}
5.8 Tool 返回值的内容类型
Tool 的返回值通过 content 数组承载,支持多种内容类型的混合返回:
| 内容类型 | type 值 | 说明 |
|---|---|---|
| 文本 | text | 最常用,纯文本结果 |
| 图片 | image | Base64 编码的图片数据 |
| 音频 | audio | Base64 编码的音频数据 |
| 资源链接 | resource_link | 指向 MCP Resource 的 URI |
| 嵌入资源 | resource | 内联的 Resource 内容 |
一个工具可以在单次调用中返回多种类型的内容。比如一个”生成图表”工具可以同时返回图片和描述文本:
{
"content": [
{ "type": "text", "text": "2024年销售趋势图:Q1-Q4 同比增长 23%" },
{ "type": "image", "data": "iVBORw0KGgo...", "mimeType": "image/png" }
]
}
此外,MCP 还支持结构化输出(Structured Content)。如果工具定义了 outputSchema,它可以在返回 content 的同时提供 structuredContent 字段,包含符合 Schema 的 JSON 对象。为了向后兼容,提供结构化输出的工具也应该在 content 中放入序列化后的 JSON 文本。
结构化输出的意义:下游可能不是 LLM 而是代码——比如 Agent 的某个后处理步骤要取 structuredContent.total_count 去做判断。如果只有 text,必须再来一次解析。
5.9 与其他工具调用格式的对比
MCP Tool 并非市场上唯一的工具调用方案。将它与几个主流方案进行对比,能更清晰地理解其设计取舍。
5.9.1 OpenAI Function Calling
OpenAI 的函数调用方案将工具定义直接嵌入 Chat Completions API 的请求体中:
{
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"parameters": { "type": "object", "properties": { "location": { "type": "string" } } }
}
}]
}
核心差异:OpenAI Function Calling 是模型 API 层面的特性,工具定义与模型请求耦合在一起。MCP Tool 则是协议层面的抽象,工具注册在独立的 Server 上,通过标准协议与任意 Client/LLM 交互。这意味着同一个 MCP Server 的工具可以被不同的大模型(Claude、GPT、Gemini)通过不同的 Client 调用,而 OpenAI Function Calling 天然绑定在 OpenAI 的 API 上。
此外,MCP 提供了 Tool Annotations 这样的安全元数据,以及 tools/list_changed 这样的动态发现机制,这些在 OpenAI 的方案中没有对应设计。
5.9.2 LangChain BaseTool
LangChain 的 BaseTool 是一个 Python 类抽象:
class GetWeatherTool(BaseTool):
name = "get_weather"
description = "查询天气信息"
args_schema = WeatherInput # Pydantic Model
def _run(self, location: str) -> str:
return fetch_weather(location)
LangChain 的工具是框架内的概念,与特定的编程语言和运行时绑定。MCP Tool 是跨进程、跨语言的协议概念——Server 可以用 Rust 编写,Client 可以用 TypeScript 编写,两者通过 JSON-RPC 通信。这种解耦带来了更强的可组合性,但也引入了网络通信的开销。
5.9.3 Claude Code 的内置 Tool
Claude Code 中的工具(如 Read、Edit、Bash)也是 Tool 的一种体现,但它们是 Client 本地注册的工具,不经过 MCP Server。它们的定义格式与 MCP Tool 高度相似(name、description、inputSchema),但生命周期完全在 Client 进程内管理。这体现了 MCP 的一个设计哲学:协议定义的是交互格式,不限制工具的来源——可以是远程 Server,也可以是本地 Client 内置。
5.9.4 对比总结
| 维度 | MCP Tool | OpenAI FC | LangChain BaseTool |
|---|---|---|---|
| 抽象层次 | 协议层 | API 层 | 框架层 |
| 跨语言 | 是(JSON-RPC) | 是(HTTP API) | 否(Python) |
| 跨模型 | 是 | 否(绑定 OpenAI) | 是 |
| 动态发现 | tools/list + 通知 | 无 | 无 |
| 安全标注 | ToolAnnotations | 无 | 无 |
| 结构化输出 | outputSchema | strict mode | 无 |
| 独立进程 | 是 | 否 | 否 |
| 鉴权机制 | OAuth 2.1(远程) | API Key | 自定义 |
5.10 Tool 的 7 种反模式
在咨询数十个 MCP Server 项目后,我整理出以下 7 种最常见反模式:
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| description 过短(如”查数据”) | LLM 无法选对 | 明确输入、输出、边界、假设 |
name 太长或含层级(company.domain.tools.get_user_by_id_v2) | LLM 分词成本高、误调 | 用扁平且简洁的 name,最多一级分隔符 |
参数语义重叠(id 和 userId 都存在) | LLM 不知该填哪个 | 合并或重命名,避免歧义 |
| 工具粒度过细(10 个只差参数的工具) | 工具爆炸,LLM 选错 | 合并为带枚举参数的工具 |
工具粒度过粗(一个 do_everything 工具) | LLM 难以构造参数 | 按动词拆分 |
| destructive 不标注 destructiveHint | Client 可能不弹确认 | 诚实标注,用户更安全 |
| 返回日志内容(堆栈/PII) | 泄露敏感信息给 LLM 和日志 | 脱敏后返回 + logging 通道单独发 |
5.11 Tool 设计 6 原则
1. 动词开头命名——get_weather、send_email、create_issue。动词让 LLM 快速理解这是一个”做事”的工具。
2. 每个参数都加 description——LLM 不仅看工具 description,还看参数 description。别吝啬字数。
3. 默认值服务于”最常见路径”——timeout: 30 好过 timeout: null,因为 LLM 更愿意调用”可以不填”的工具。
4. 错误消息可操作——“参数错误”是废话,“location 必须是城市名或邮政编码”才能让 LLM 自我修正。
5. 只返回必要数据——LLM 上下文是黄金。一个 list 工具如果能返回 3 个核心字段就够了,就别返回 30 个。
6. 诚实标注 annotation——欺骗标注 = 让用户失去保护。这是信誉问题。
5.12 安全考量与最佳实践
协议在安全方面提出了明确的要求,这些要求直接影响生产环境中 Tool 的设计与部署。
Server 侧必须做到的事:
- 验证所有工具输入,不信任来自 Client 的任何参数
- 实施访问控制,确保工具只能被授权的 Client 调用
- 对工具调用进行速率限制,防止被恶意利用
- 清洗工具输出,避免敏感信息泄露
Client 侧应该做到的事:
- 在敏感操作前向用户显示确认提示
- 在调用 Server 之前向用户展示工具输入,防止恶意或意外的数据泄露
- 对工具调用实施超时控制
- 记录工具使用日志以供审计
一个经常被忽视的安全风险是:大模型构造的工具参数本身可能包含注入攻击。比如一个执行 SQL 查询的工具,如果 Server 端没有做参数化查询,大模型构造的 query 参数就可能包含 SQL 注入。MCP 协议通过要求 Server 验证所有输入来防范这类风险,但最终的安全保障取决于具体的实现质量。
5.13 本章小结
Tool 是 MCP 协议中赋予大模型行动能力的核心原语。本章从协议规范到 SDK 实现,系统梳理了 Tool 的完整技术栈:
- 定义层面:Tool 通过 name、description、inputSchema(JSON Schema)构成声明式的函数接口,大模型据此理解”能做什么”和”怎么调用”
- 生命周期:
tools/list发现工具 →tools/call调用工具 →tools/list_changed动态更新,构成完整的工具管理循环 - SDK 实现:TypeScript SDK 的
registerTool和 Python SDK 的@tool装饰器,虽然 API 风格不同,但内核一致——名称校验、Schema 生成、请求处理器注册、变更通知 - 安全体系:Tool Annotations 的四个 hint(readOnly、destructive、idempotent、openWorld)为 Agent 的自动化决策提供了关键的安全元数据
- 错误处理:协议层错误与业务层错误的双层设计,使大模型能够区分”请求有误”和”执行失败”,从而采取不同的恢复策略
一句话记忆:
Tool 是把 LLM 的”说”翻译成世界的”动”——description 是选择器,Schema 是守门员,annotation 是安全带,content 是回声。
在下一章中,我们将深入 Resource 原语——与 Tool 让 Server 暴露”动作”给大模型的方向不同,Resource 让 Server 把”上下文”声明式地提供给 Client,由应用程序而非 LLM 来决定何时注入到对话中。
延伸阅读:MCP Tool 和 OpenAI Function Calling 的深层差异
表面看——MCP Tool 和 OpenAI Function Calling 功能相似——都是”LLM 调用外部函数”。实际上——两者有本质差异。OpenAI Function Calling——“LLM 内置能力”——函数描述随 prompt 发给 LLM、每次调用都带完整工具集、LLM 直接返回函数调用。MCP Tool——“协议层能力”——Server 声明工具、Client 按需呈现给 LLM、LLM 调用结果反馈回 Server——多了一层协议抽象。
MCP 的多一层抽象、带来灵活性——“工具可以跨 LLM 复用”(同一个 Tool 在 GPT、Claude、Gemini 里都能用)、“工具可以独立演化”(不依赖 LLM 提供商的发布节奏)、“工具可以本地运行”(隐私敏感场景)。代价是——“调用链更长”(Client 要 marshal/unmarshal)、“配置更复杂”(需要起一个 MCP Server)。对大多数场景、MCP 的灵活性胜过复杂度——这也是它快速成为事实标准的原因。
延伸阅读:Tool Schema 的类型安全
MCP Tool 的参数 Schema 用 JSON Schema 描述——这让”类型安全”从编程语言延伸到协议层。LLM 调用 Tool 时、Client 可以按 Schema 验证 LLM 的输出(类型对不对、必填字段有没有、格式合不合法)——不合规的调用直接拒绝、不发给 Server。这层验证、让 Server 不用每次都防御”恶意”或”误用”的输入——降低了 Server 实现的复杂度。
JSON Schema 是 IETF 的标准(RFC 8259 Draft 20 等)——广泛支持、工具链成熟(ajv、jsonschema、Pydantic 等)。MCP 选用 JSON Schema——借用了整个生态——不需要发明新的 Schema 语言。这种”站在标准肩膀上”的选择、是协议设计的务实。《Serde 元编程》讨论的类型驱动开发、《LangChain 源码》讨论的结构化输出——都和 JSON Schema 的应用相关、值得对照。
延伸阅读:Description 是 Tool 的生死线
本章用”description 是选择器”概括——这是 Tool 设计最重要的洞察。LLM 看不到你的代码——只看得到 description。description 写得好——LLM 能准确判断”什么时候调用、传什么参数”;写得差——LLM 要么该用时不用、要么用错场景、要么传错参数。Tool description 的质量、直接决定 Tool 的实际价值。
好的 description 有几个特点——“起头说场景”(“当用户问股价时使用”)、“给正例”(“比如:get_stock(‘AAPL’)”)、“标清楚边界”(“只支持美股、A 股请用 get_cn_stock”)、“避免技术缩写”(不要 “HTTP 404”、写 “资源不存在”)。这些看起来细节、但真实影响 LLM 的选择准确率。《Harness Engineering》第 5 章讨论的 Tool 描述工程、和本章主题高度重合——两本书对读能有互补。
延伸阅读:Annotation 作为安全带
MCP 的 Tool annotation(readOnlyHint、destructiveHint、idempotentHint、openWorldHint)——是 Tool 的”语义标签”——让 Client 能根据语义做权限控制、UI 提示、重试策略。readOnlyHint——只读工具、默认可以放心调用;destructiveHint——破坏性工具、需要用户确认;idempotentHint——幂等工具、可以放心重试;openWorldHint——访问外部世界(网络、数据库)、需要网络权限。
这种”元数据驱动的安全”、让 Client 能为不同风险级别的 Tool 提供不同 UI。只读工具可以直接调;破坏性工具要弹确认框;幂等工具可以自动重试;open world 工具要显示网络状态——都是自动化的。这比”所有工具一视同仁”要友好得多。《OpenClaw 源码》第 13 章的权限分级、《Harness Engineering》第 14 章的权限模型——都有类似思路——这是 AI 工具系统的共同安全工程。
延伸阅读:Tool 调用的错误语义
Tool 调用可能失败——MCP 对此有明确规范。“协议级错误”——通信失败、Schema 违规——通过 JSON-RPC error 返回;“业务级错误”——查询不到数据、参数组合非法——通过 content 的 isError 字段返回。这种”分类错误”、让 Client 能精确处理——协议错要重试、业务错要给用户友好消息。
没有这种分类——所有错误都一视同仁——Client 很难做出合理响应。这和 HTTP 的 4xx vs 5xx、gRPC 的 status code 分类——都是同一种思路——“按错误性质分级处理”。MCP 把这种成熟经验应用到 AI 工具协议——让”错误处理”从”玄学”变成”工程”。
延伸阅读:Content 作为回声
Tool 返回的 content——可以是 text、image、audio、resource——这种”多模态返回值”让 Tool 不局限于”返回字符串”。一个”查询用户头像”的工具——直接返回 image、LLM 能”看到”头像;一个”读取文档”的工具——返回 resource、引用可以在对话里复用;一个”生成报告”的工具——返回 text + image——LLM 能综合理解。
这种”多模态返回”的设计、是为了适配”多模态 LLM”(GPT-4V、Claude 3 Opus、Gemini Pro)——让 Tool 不只能和文本交互、也能和图像、音频交互。未来 LLM 会支持更多模态(视频、3D 模型、触觉)——MCP 的 content 类型体系能自然扩展。这是”协议为未来而设计”的体现——而不是”只为当下需求”。
延伸阅读:Tool 的发现机制
MCP 的 tools/list 方法——让 Client 能动态发现 Server 提供的工具。这种”运行时发现”、比”编译期静态声明”更灵活——Server 可以根据用户身份、时间、配置动态返回不同的工具集。这让一个 MCP Server 能服务多种场景——企业用户看到企业工具、个人用户看到个人工具——基于同一套代码。
运行时发现的类似机制、在其他协议里也有——OpenAPI 的 spec、gRPC 的 reflection、GraphQL 的 introspection——都是”让客户端问’你能做什么’“。MCP 延续这个传统——让 AI 工具生态能像 API 生态一样自动适配。这种”自描述协议”、是生态繁荣的基础——让工具能像互联网页面一样自然被发现和使用。
延伸阅读:Tool 的版本管理
Tool 版本管理是”生产级 MCP Server”的重要考量。随着时间推移——Tool 的参数会变、行为会调整、新能力会加入——这些变化需要版本化管理、让 Client 能适配。常见做法——“Tool 名字带版本”(get_stock_v2)、“在 annotation 里标注版本”、“通过 capability 声明支持的版本范围”。
版本管理的哲学——“老工具不删、新工具加入”——让已有 Client 继续工作、新 Client 享受新能力。破坏性变更——用”deprecation 预告”——提前告诉用户”这个工具将在 X 版本删除、请迁移到 Y”——给用户时间适配。这种”优雅演化”的纪律、是开源工具长期成功的关键。《LangChain 源码》第 17 章讨论的 Partner 版本管理——也涉及类似思路。
延伸阅读:Tool 的分类命名
当一个 MCP Server 提供几十个 Tool 时——“命名”成为重要问题。好的命名能让 LLM 快速选到正确工具、坏的命名让 LLM 迷失。建议的命名约定——“动词_名词”(get_stock、create_issue)、“resource/action”(stock/get、issue/create)——任选一种、全局保持一致。避免——“一词多义”(process 到底做什么?)、“技术缩写”(svc/api_v2/mgr_h)、“不同风格混用”(一部分 get_xxx、一部分 fetch_yyy)。
好的命名、是”让 LLM 不用猜”——看名字就知道作用。这和给人类看的代码命名原则一致——但对 LLM 要求更严格(LLM 不会去看源码猜意图)。这种”对 LLM 友好的命名”能力、是 AI 时代的新技能——值得每个 Tool 作者认真对待、不要当成”顺手起个名字”的小事。
延伸阅读:Tool 链式调用的设计
复杂 Agent 场景、常常需要”链式调用”——先调 get_user 拿到 user_id、再调 list_orders(user_id)、再调 get_order_detail(order_id)——多个 Tool 串起来完成一个任务。MCP 对这种场景没有特殊协议——由 LLM 自己理解、一步步调用。但 Tool 设计者可以通过”返回值包含下一步 hint”来引导 LLM。
比如 get_user 返回值里可以包含”related_orders_url”提示——让 LLM 知道”下一步可以调 list_orders”。这种”链式提示”、让 Tool 变得”可组合”——LLM 能通过文档引导、自然地完成多步任务。好的 Tool 链设计、让复杂任务的完成率大幅提升。
延伸阅读:Tool 性能优化的层次
Tool 性能优化有多个层次——“Server 内部优化”(算法、缓存、DB 索引)、“协议层优化”(减少 round trip、批量调用)、“LLM 层优化”(减少 token 消耗、提高选择准确率)。每个层次都值得投入。Server 优化——让单次调用快;协议优化——让多次调用总延迟低;LLM 优化——让调用更少。
优化的优先级——“先 LLM 层、再协议层、再 Server 层”——因为 LLM 调用通常是最慢的。如果能让 LLM “每次正确调用、不需要重试”——就胜过任何 Server 层优化。这种”先优化最慢的环节”的思路、是 Amdahl 定律的体现——系统整体性能由最慢环节决定。《vLLM 源码》讨论的 LLM 推理优化、《Tokio 源码》讨论的异步性能——都在这个框架下——跨书对照能建立系统级的性能思维。
延伸阅读:Tool 测试的策略层次
Tool 测试有三个层次——“unit test”(测试 Tool 内部逻辑、mock 外部依赖)、“integration test”(测试 Tool 和真实依赖的集成)、“LLM-in-the-loop test”(测试 LLM 能否正确调用 Tool)。三者缺一不可。Unit test 保证逻辑正确——快速反馈;integration test 保证集成可用——避免”mock 通过、真实不通”;LLM test 保证 LLM 能用好——这是 Tool 的最终价值。
LLM-in-the-loop test 是 AI 时代的新概念——给 LLM 一组典型任务、看它是否能正确调用 Tool 完成。这种测试成本高(调 LLM 要钱)、但价值大(这才是用户实际体验)。在关键 Tool 上投入 LLM test、是生产级 MCP Server 必须的。Claude Code、Cursor 等头部产品、都有完整的 LLM test 流水线。
延伸阅读:Tool 的审计合规
企业场景、Tool 调用必须审计——“谁在什么时候调用了什么 Tool、传了什么参数、收到什么结果”——这些要留痕、支持事后追溯。合规要求(SOX、HIPAA、GDPR、ISO 27001、等保三级)、很多都涉及”关键操作必须审计”。MCP 本身没规定审计——但 Server 实现应该自己加审计日志。
好的审计日志——记录全、不可篡改、可查询。技术实现——WAL(Write-Ahead Log)保证不丢、加密存储保证不被篡改、索引优化保证可查、冷热分层保证成本可控。对合规严格的企业(金融、医疗、政府、军工)——MCP Server 的审计能力是选型关键、没有审计能力的 Server 直接被排除。这也是”企业级 MCP Server”区别于”玩具级 MCP Server”的重要标志——企业采购时会认真审查这些能力、所以生产级 Server 必须从第一天就把这些能力考虑进去。
延伸阅读:Tool 的国际化
面向全球用户的 Tool——必须考虑国际化。描述要本地化——英文用户看英文、中文用户看中文、日文用户看日文、阿拉伯用户看阿拉伯文。参数含义要跨文化一致——“city”这个参数、是 “New York” 还是 “纽约”?还是”NY”?需要明确约定。错误消息要本地化——给中国用户报”用户不存在”、给法国用户报”Utilisateur inexistant”、给日本用户报”ユーザーが存在しません”。
实现上——MCP 支持通过 Accept-Language 头协商语言、Server 据此返回对应语言的描述和错误。这是 HTTP 经典机制在 MCP 里的应用。国际化看起来工作量大、但对全球用户是必需的——没有国际化的 Tool、非英语用户使用体验会打折扣、甚至完全无法使用。《Vite 源码》第 11 章讨论的 HTML 处理国际化——也是类似话题、都涉及”让产品跨文化被使用”的工程智慧。
延伸阅读:Tool 的演化路径
初学者写 Tool 的典型演化——“第一版:能跑就行”(硬编码、无错误处理)、“第二版:规范参数”(加 schema 验证)、“第三版:错误分类”(区分协议错和业务错)、“第四版:加 annotation”(标注 readonly/destructive)、“第五版:加审计”(日志留痕、监控告警)、“第六版:LLM-test”(确保 LLM 能正确调用)、“第七版:国际化和多租户”(企业级支持)。
每一步升级、都让 Tool 从”玩具”向”生产级”迈进——每一步都对应一类真实问题的解决。这个路径没有捷径——需要时间和经验积累、急不得也省不了。但好消息是——每一步都有明确收益、不会白投入——是”确定性的改进”、不是赌博。希望本章能帮读者在这条路径上少走弯路、更快到达生产级 Tool 的水平、做出被广泛采用的 MCP Server。
延伸阅读:Tool 的社区分享
好的 Tool 应该分享出来——放到 MCP Server Registry、写个博客介绍、发到 Hacker News 或 Reddit 或小红书、在 Twitter 上宣传。开源分享的好处——“让更多人受益”、“收到反馈改进”、“建立个人品牌”、“结识志同道合的朋友”——每一个都是长期价值。2026 年的 MCP 生态、正处于”淘金期”——早期贡献者有机会在某个细分领域建立影响力、变成该领域的代表性人物。
分享的具体路径——“先在 GitHub 开源”(让别人能读、能 fork)、“再写 README 和示例”(降低使用门槛)、“再发布到 MCP Registry”(让更多人发现)、“再写博客或视频”(深度解释设计)。这套组合拳、能让一个好 Tool 在 2-3 周内被数百开发者发现和采用——从无到有的传播速度惊人。这比”默默写完不管”要有价值得多——开源不只是”给代码”、更是”建立连接”——和其他开发者、和未来的机会。