MCP 协议设计与实现

第5章 Tool:让 Agent 调用世界

作者 杨艺韬 · 7,857 字 · 发布于 · 更新于

"The limits of my language mean the limits of my world."

—— Ludwig Wittgenstein,《Tractatus Logico-Philosophicus》5.6("Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt.")

把"语言"换成"工具",这句话就是本章要讲的事:工具的边界就是 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,晴转多云"

在这个流程中,有几个值得注意的设计原则:

  1. 大模型做选择,Client 做守门。大模型决定调用哪个 Tool、传什么参数,但 Client 应该(SHOULD)在实际发送 tools/call 之前向用户展示确认提示,确保 human-in-the-loop。
  2. Server 做执行,协议做规范。Server 负责实际执行工具逻辑并返回结果,但返回的格式严格遵循协议定义的 CallToolResult 结构。
  3. 工具是声明式的。每个 Tool 通过 JSON Schema 声明自己接受什么参数,大模型据此构造合法的调用请求。

这三层分工,构成了 Tool 作为"意图—守门—执行"三级流水线的完整闭环。

5.2 Tool 的定义结构

5.2.1 字段全景

schema/2025-11-25/schema.json 里 Tool 一共 9 个属性,其中只有 name 与 inputSchema 是 required:

字段 类型 必需 说明
name string 是 工具的唯一标识符。JSON Schema 层面没有任何长度或字符约束(就是个 string);命名规约由 SEP-986 以 SHOULD 级别给出,见下方说明
title string 否 人类可读的显示名称
description string 否 功能描述,帮助大模型理解何时使用
inputSchema JSON Schema 是 输入参数的 JSON Schema 定义
outputSchema JSON Schema 否 输出结构的 JSON Schema 定义
annotations ToolAnnotations 否 行为标注(只读、破坏性等)
icons Icon[] 否 供 Client 在界面上展示的多尺寸图标
execution ToolExecution 否 目前只有一个 taskSupport 字段:"forbidden"(默认)/ "optional" / "required",声明该工具是否走 task 增强执行
_meta object 否 通用扩展字段

这 9 个字段不是一次到齐的。逐版对照 schema/<版本>/schema.json:2024-11-05 初版只有 name、description、inputSchema;annotations 在 2025-03-26 加入;title、outputSchema、_meta 在 2025-06-18 加入;icons 与 execution 在 2025-11-25 加入(execution 服务于同版引入的实验性 Tasks)。本章以 2025-11-25 为准;2026-07-28 版已发布,其中 Tasks 移出核心、Tool 随之去掉 execution,所有结果(包括 CallToolResult)新增必填的 resultType,notifications/tools/list_changed 也改为只发给通过 subscriptions/listen 订阅了的 Client。

工具名称的命名规范值得特别关注,但要先分清它是哪一级的约束。

JSON Schema 里什么都没规定——schema/2025-11-25/schema.json 中 Tool.name 除了一段 description,就只有 {"type": "string"},整份 schema 里唯一的 minLength/maxLength 出现在 StringSchema(elicitation 用的用户输入类型)上,与工具名无关。

规约来自 SEP-986,写在规范正文里(server/tools.mdx),全部是 SHOULD 而非 MUST:

  • 长度 SHOULD 在 1–128 字符之间(含两端)
  • 仅使用 A-Z、a-z、0-9、_、-、.
  • 区分大小写
  • 不应包含空格、逗号或其他特殊字符

合法的例子:getUser、DATA_EXPORT_v2、admin.tools.list。

两个 SDK 都实现了这条校验,但只告警不拒绝——TS 侧 packages/core/src/shared/toolNameValidation.ts、Python 侧 src/mcp/shared/tool_name_validation.py,两边用的是同一条正则 ^[A-Za-z0-9._-]{1,128}$,超长或含非法字符时返回 warning 列表并记日志,注册照常进行。

这个"SHOULD + 只告警"的组合有实际后果:一个带空格的工具名不会在你本地报错,但可能在某个严格实现的 Client 上出问题。真要跨生态互操作,就当它是 MUST 来写。

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,而且 schema.ts 把它根上的 type 限定为 "object"(outputSchema 同样如此)。对于没有参数的工具,推荐使用 { "type": "object", "additionalProperties": false },明确表示只接受空对象。没有 $schema 字段时默认按 JSON Schema 2020-12 解释(2025-11-25 由 SEP-1613 确立),也可以通过 $schema 字段显式指定其他方言。

5.2.3 "description" 的隐藏分量

看似不起眼的 description 字段,其实是 Tool 定义中最重要的字段之一——因为它直接决定了大模型是否能"选对"这个工具。

看两个对比:

{ "description": "查询天气" }
{
  "description": "查询指定城市的**当前**(今天)天气。不支持历史天气或未来预报。支持中国大陆、香港、台湾、海外主要城市。参数 location 支持城市名(如'北京')或邮政编码(如'100000')。"
}

一个只有 4 个字,一个有近 60 字并明确了边界、参数格式、支持范围。在 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"
  }
}

cursor 是 Server 自定的不透明字符串,规范要求 Client 把它当不透明令牌对待(MUST),不能自行解析或拼造;有下一页时 Server 才返回 nextCursor。两个 SDK pin 版本的高层 API(TS McpServer、Python MCPServer)在 tools/list 里都是一次返回全部工具,不带 nextCursor。

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 才合适?

社区实践里流传的一个粗略经验(没有公开的对照实验支撑,只能当作工程直觉,不要当数据引用):

Tool 数量 LLM 表现 典型问题
1-5 个 极佳 功能覆盖有限
6-15 个 优秀 最常见的"sweet spot"
16-30 个 良好 需要精心设计 name/description
31-50 个 下降 选择困难,建议分组
50+ 显著下降 必须配合工具搜索机制

这组数字不是硬性规则,它反映的是一种工程直觉:LLM 的"工具选择空间"是有实际上限的,和人类的"选择悖论"类似。如果你的 Server 不得不暴露 50+ 个 Tool,应该考虑:

  1. 合并近似工具——search_user、search_product、search_order 合并为 search(entity, query)
  2. 层次化组织——暴露 list_tools 元工具,按需加载具体工具
  3. 拆分 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,这个方法完成了几个关键步骤:

  1. 名称校验:调用 validateAndWarnToolName(name) 检查名称是否符合规范。
  2. 构造执行器:调用 createToolExecutor(inputSchema, handler),把 schema 与回调打包成 RegisteredTool.executor。注意 Zod schema 不是在这里转 JSON Schema 的——standardSchemaToJsonSchema() 要等到 tools/list 处理器返回列表时才按需调用(mcp.ts 中 setToolRequestHandlers 的 tools/list 分支)。
  3. 注册请求处理器:首次注册工具时,调用 setToolRequestHandlers() 为 tools/list 和 tools/call 设置处理逻辑,并顺带 registerCapabilities({ tools: { listChanged: … ?? true } })。
  4. 发送变更通知:调用 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 装饰器内部做了什么?从源码可以看到完整的流程:

  1. 函数元数据提取:调用 func_metadata(fn) 解析函数签名,自动生成 Pydantic 模型作为参数校验的基础。
  2. JSON Schema 生成:通过 model_json_schema() 从 Pydantic 模型自动生成 JSON Schema,作为工具的 inputSchema。
  3. Context 注入检测:通过 find_context_parameter(fn) 扫描函数签名,如果发现类型标注为 Context 的参数,就标记为 context 注入点,在调用时自动注入上下文对象。
  4. 工具注册:通过 ToolManager.add_tool() 将构造好的 Tool 对象注册到管理器中。

上面四步实际都发生在 add_tool() 调用的 Tool.from_function() 里,它还会先调用 validate_and_warn_tool_name() 做名称校验。另外,func_metadata() 默认还会根据返回值类型标注推导 outputSchema(structured_output=None 时自动检测):像上例 -> str 这种非对象类型,会被包成 {"result": ...},于是 tools/list 里会出现 {"properties": {"result": {"type": "string", ...}}, "required": ["result"]} 这样的 outputSchema,tools/call 的结果也会同时带上 structuredContent: {"result": "北京:25°C"}(pin 版本实跑确认)。不想要结构化输出,就在装饰器里传 structured_output=False。

Python SDK 中一个巧妙的设计是 Context 注入。开发者只需在函数签名中添加一个 ctx: Context 参数,SDK 就会自动将 MCP 上下文注入,无需额外配置:

from mcp.server.mcpserver import Context

@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["tools/list 时 standardSchemaToJsonSchema()"]
        TS_HDL["setToolRequestHandlers()"]
        TS_NTF["sendToolListChanged()"]
        TS_REG --> TS_VAL
        TS_REG --> TS_HDL
        TS_REG --> TS_NTF
        TS_HDL --> TS_SCH
    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 方法中:

// packages/server/src/server/mcp.ts:240(简化:源码带泛型参数、签名是多行,这里去掉了类型标注)
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 完成:

# src/mcp/server/mcpserver/tools/base.py(节选,去掉了类型标注与 docstring)
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 is not None else None,
        )
        if convert_result:
            result = self.fn_metadata.convert_result(result)
        return result
    except UrlElicitationRequiredError:
        raise  # 与 TS 一样,URL elicitation 错误原样上抛
    except Exception as e:
        raise ToolError(f"Error executing tool {self.name}: {e}") from e

Python SDK 利用 Pydantic 的 model_validate 进行校验,这意味着不仅能检查类型,还能执行自定义验证器(validator)。

这里有一个容易想当然的地方:validateToolInput 确实抛的是 new ProtocolError(ProtocolErrorCode.InvalidParams, …),但它是在 tools/call 处理器的 try 块内部抛的,外层 catch 会把它转成 createToolError(...),也就是 isError: true 的 CallToolResult——而不是 JSON-RPC 错误响应。这正是规范要求的行为:server/tools.mdx 明确把 "Input validation errors" 归入 Tool Execution Errors 一类(2025-11-25 经 SEP-1303 澄清),好让模型看到错误信息后自行改参数重试。在这个处理器里,走 JSON-RPC error 通道的是 try 块之外抛出的两种情况——工具不存在(Tool X not found)和工具被 disable(Tool X disabled),外加 catch 里原样重抛的 URL elicitation 错误(见 §5.7.3)。

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 工具是否可能执行破坏性更新(false 表示只做增量更新;仅在 readOnlyHint 为 false 时有意义) 删除数据、覆盖文件、发送不可撤回消息
idempotentHint false 相同参数重复调用是否不会对环境产生额外影响(仅在 readOnlyHint 为 false 时有意义) PUT 操作、幂等更新
openWorldHint true 工具是否与开放世界交互 Web 搜索(开放)vs 内存工具(封闭)

ToolAnnotations 在 2025-03-26 加入,除了这四个 hint 还有一个 title 字段;显示名的优先级是 Tool.title、annotations.title、name(见 schema.ts 中 Tool.annotations 的注释)。

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/tools.mdx 的原话是:除非来自受信任的 Server,否则 Client 必须(MUST)把工具标注视为不可信。一个恶意 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 处理器中,如果工具执行抛出异常(URL elicitation 那一种 ProtocolError 除外),SDK 会自动将其包装为 isError: true 的业务层错误:

// packages/server/src/server/mcp.ts 中 tools/call 处理器(节选,省略了 task 相关分支)
try {
    const args = await this.validateToolInput(tool, request.params.arguments, request.params.name);
    const result = await this.executeToolHandler(tool, args, ctx);
    // 定义了 outputSchema 时校验 structuredContent,失败同样抛 ProtocolError
    await this.validateToolOutput(tool, result, request.params.name);
    return result;
} catch (error) {
    // 只有 URL elicitation 这一种协议错误原样抛出,交给调用方处理
    if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) {
        throw error;
    }
    // 其余一切异常(含输入校验失败)都包装为业务层错误
    return this.createToolError(error instanceof Error ? error.message : String(error));
}

pin 版本实跑可见:参数类型不对、handler 抛错、声明了 outputSchema 却没返回 structuredContent,三种情况拿到的都是 isError: true 的结果;调用不存在或已 disable 的工具,拿到的是 -32602 的 JSON-RPC 错误。Python SDK 的 MCPServer 在这点上不同:_handle_call_tool 除 MCPError 外一律包成 isError: true,连未知工具也是如此(实跑返回 "Unknown tool: nope" 文本加 isError: true),而规范把 Unknown tools 列在协议错误一类。

5.8 Tool 返回值的内容类型

Tool 的返回值通过 content 数组承载,支持多种内容类型的混合返回:

内容类型 type 值 说明
文本 text 最常用,纯文本结果
图片 image Base64 编码的图片数据
音频 audio Base64 编码的音频数据
资源链接 resource_link 指向 MCP Resource 的 URI
嵌入资源 resource 内联的 Resource 内容

其中 text、image、resource 在 2024-11-05 初版就有,audio 于 2025-03-26 加入,resource_link 于 2025-06-18 加入。

一个工具可以在单次调用中返回多种类型的内容。比如一个"生成图表"工具可以同时返回图片和描述文本:

{
  "content": [
    { "type": "text", "text": "2024年销售趋势图:Q1-Q4 同比增长 23%" },
    { "type": "image", "data": "iVBORw0KGgo...", "mimeType": "image/png" }
  ]
}

此外,MCP 从 2025-06-18 起支持结构化输出(Structured Content):工具可以在返回 content 的同时提供 structuredContent 字段,内容是一个 JSON 对象。如果工具定义了 outputSchema,Server 必须(MUST)返回符合该 Schema 的结构化结果,Client 应该(SHOULD)据此校验。为了向后兼容,提供结构化输出的工具也应该(SHOULD)在 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 类抽象:

from langchain_core.tools import BaseTool
from pydantic import BaseModel

class WeatherInput(BaseModel):
    location: str

class GetWeatherTool(BaseTool):
    name: str = "get_weather"
    description: str = "查询天气信息"
    args_schema: type[BaseModel] = 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 高度相似(名称、描述、输入参数的 JSON Schema),但生命周期完全在 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 风格不同,都会做名称校验(只告警)和 JSON Schema 生成;TS 版还在首次注册时挂上请求处理器,并在每次增删改后(已连接时)自动发送 list_changed 通知
  • 安全体系:Tool Annotations 的四个 hint(readOnly、destructive、idempotent、openWorld)为 Agent 的自动化决策提供了关键的安全元数据
  • 错误处理:协议层错误与业务层错误的双层设计,使大模型能够区分"请求有误"和"执行失败",从而采取不同的恢复策略

一句话记忆:

Tool 是把 LLM 的"说"翻译成世界的"动"——description 是选择器,Schema 是守门员,annotation 是安全带,content 是回声。

在下一章中,我们将深入 Resource 原语——与 Tool 让 Server 暴露"动作"给大模型的方向不同,Resource 让 Server 把"上下文"声明式地提供给 Client,由应用程序而非 LLM 来决定何时注入到对话中。