MCP 工具详解:定义、结果、错误与跨版本差异
工具是服务器公开、由模型调用的一个具名操作。一次调用经过五步:定义、列表、调用、结果、失败。下面按 2026-07-28 修订版走完这条路径,并逐个修订版说明自 2024-11-05 以来改了什么,同时说明错误该走哪条通道。
怎样把工具设计得让模型好用,见 MCP 服务器最佳实践。要在运行中的服务器上列出、调用工具,并区分连接失败与工具失败,继续看 MCP Inspector 测试指南。规范的约束文本见官方 工具页。
一、一次工具调用的路径
| 步骤 | 消息 | 要点 | 章节 |
|---|---|---|---|
| 1 | 能力声明 | 支持工具的服务器必须声明 tools 能力;listChanged 表示列表变化时是否通知客户端 | 七 |
| 2 | tools/list | 支持分页(cursor、nextCursor)与缓存;结果带 ttlMs 和 cacheScope | 三、七 |
| 3 | tools/call | name 与 arguments。在 Streamable HTTP 上,工具名还会放进 Mcp-Name 头 | 三、五 |
| 4 | 结果 | CallToolResult(content、可选的 structuredContent、isError),或要求补充输入后才能完成调用的 input_required 结果 | 三、五 |
| 5 | 列表变更 | 声明了 listChanged 时,服务器向以 toolsListChanged: true 打开了 subscriptions/listen 的客户端发送 notifications/tools/list_changed,客户端重新列出 | 七 |
二、工具定义在五个修订版中的变化
任何修订版里都只有 name 与 inputSchema 是必填的,其余字段都是后来加的。下表由各修订版 schema.json 的 definitions / $defs 生成,— 表示该修订版没有定义这个字段。本表及后面的表格用年月标注列:2024-11 即 2024-11-05,2025-03 即 2025-03-26,2025-06 即 2025-06-18,2025-11 即 2025-11-25,2026-07 即 2026-07-28。
| 字段 | 2024-11 | 2025-03 | 2025-06 | 2025-11 | 2026-07 |
|---|---|---|---|---|---|
Tool.name | 必填 | 必填 | 必填 | 必填 | 必填 |
Tool.title | — | — | 可选 | 可选 | 可选 |
Tool.description | 可选 | 可选 | 可选 | 可选 | 可选 |
Tool.inputSchema | 必填 | 必填 | 必填 | 必填 | 必填 |
Tool.outputSchema | — | — | 可选 | 可选 | 可选 |
Tool.annotations | — | 可选 | 可选 | 可选 | 可选 |
Tool.icons | — | — | — | 可选 | 可选 |
Tool.execution | — | — | — | 可选 | — |
Tool._meta | — | — | 可选 | 可选 | 可选 |
ToolAnnotations.title | — | 可选 | 可选 | 可选 | 可选 |
ToolAnnotations.readOnlyHint | — | 可选 | 可选 | 可选 | 可选 |
ToolAnnotations.destructiveHint | — | 可选 | 可选 | 可选 | 可选 |
ToolAnnotations.idempotentHint | — | 可选 | 可选 | 可选 | 可选 |
ToolAnnotations.openWorldHint | — | 可选 | 可选 | 可选 | 可选 |
annotations及其提示字段出现在 2025-03-26;title、outputSchema、_meta出现在 2025-06-18;icons出现在 2025-11-25。Tool.execution只存在于 2025-11-25 的 schema,2026-07-28 的 schema 中没有它,tools/call的task参数也一样(第三节)。- 注解只是提示。规范要求客户端必须把工具注解视为不可信,除非它来自可信的服务器。
schema 表达不了的部分(均出自官方工具页):
- 名称。 名称应当为 1 到 128 个字符,区分大小写,只用字母、数字、
_、-和.;名称在同一服务器内应当唯一。聚合多个服务器的客户端或代理应当消解冲突,例如加服务器标识前缀,并且不应依赖serverInfo里的服务器name。 inputSchema。 它必须是合法的 JSON Schema 对象(不能是null),没有$schema时默认 2020-12。无参数工具推荐写{ "type": "object", "additionalProperties": false },它只接受空对象;{ "type": "object" }接受任意对象。x-mcp-header。 写在属性 schema 里的注解,在 Streamable HTTP 上把该参数镜像为Mcp-Param-{name}HTTP 头。它有硬性约束,违反的工具会被客户端丢弃(第七节)。
三、列表与调用:字段与取值
第一张表列出三类关键消息的字段。tools/call params 自 2025-11-25 起是具名定义,之前是内联对象;_meta 不在这一组里,因为旧的内联定义没有列出它(它在 2026-07-28 的必填要求由第四节的矩阵覆盖)。
| 字段 | 2024-11 | 2025-03 | 2025-06 | 2025-11 | 2026-07 |
|---|---|---|---|---|---|
ListToolsResult.tools | 必填 | 必填 | 必填 | 必填 | 必填 |
ListToolsResult.nextCursor | 可选 | 可选 | 可选 | 可选 | 可选 |
ListToolsResult.ttlMs | — | — | — | — | 必填 |
ListToolsResult.cacheScope | — | — | — | — | 必填 |
ListToolsResult.resultType | — | — | — | — | 必填 |
ListToolsResult._meta | 可选 | 可选 | 可选 | 可选 | 可选 |
tools/call params.name | 必填 | 必填 | 必填 | 必填 | 必填 |
tools/call params.arguments | 可选 | 可选 | 可选 | 可选 | 可选 |
tools/call params.inputResponses | — | — | — | — | 可选 |
tools/call params.requestState | — | — | — | — | 可选 |
tools/call params.task | — | — | — | 可选 | — |
CallToolResult.content | 必填 | 必填 | 必填 | 必填 | 必填 |
CallToolResult.structuredContent | — | — | 可选 | 可选 | 可选 |
CallToolResult.isError | 可选 | 可选 | 可选 | 可选 | 可选 |
CallToolResult.resultType | — | — | — | — | 必填 |
CallToolResult._meta | 可选 | 可选 | 可选 | 可选 | 可选 |
第二张表列出不是字段名的取值形态:
| 方面 | 2024-11 | 2025-03 | 2025-06 | 2025-11 | 2026-07 |
|---|---|---|---|---|---|
schema.json 的 JSON Schema 方言 | draft-07 | draft-07 | draft-07 | 2020-12 | 2020-12 |
content 块的 type 取值 | text, image, resource | text, image, audio, resource | text, image, audio, resource_link, resource | text, image, audio, resource_link, resource | text, image, audio, resource_link, resource |
structuredContent | — | — | object | object | 任意 JSON 值 |
outputSchema 根部 | — | — | 根部 type 必须为 object | 根部 type 必须为 object | 任意 JSON Schema |
实际意味着:
resultType变为必填(2026-07-28 的两个结果)。仍按 2025-11-25 的方式、不带它回应tools/call或tools/list的服务器,不再通过校验(第四节)。tools/list必须带缓存提示。 服务器必须在resultType: "complete"的tools/list结果里带缓存提示,且ttlMs必须>= 0;ttlMs: 0表示立即过期。structuredContent可以是任意 JSON 值(对象、数组、字符串、数字、布尔或 null),如果有outputSchema,则须符合它。2026-07-28 之前它必须是对象,outputSchema的根也一样。- 输出契约。 声明了
outputSchema时,服务器必须提供符合它的结构化结果,客户端应当校验。为向后兼容,返回结构化内容的工具应当同时在TextContent块里返回其序列化 JSON。 - 内容块有
text、image、audio、resource_link和(内嵌的)resource。工具返回的资源链接不保证出现在resources/list中。 - 需要输入。 服务器可以用
InputRequiredResult(resultType: "input_required")回应tools/call。客户端带上inputResponses和(若服务器给了)requestState重试,且首次请求与重试的 JSON-RPCid必须不同。带inputResponses或requestState的重试结果不得缓存。
四、同一条消息,五份 schema
对某个修订版有效的消息,在其他修订版里还成立吗?每一行都对五个 schema.json 分别校验。✓ 表示有效,✗ 表示被拒绝,— 表示该修订版没有定义这一行所涉及的属性,校验说明不了什么。
| 消息 | 2024-11 | 2025-03 | 2025-06 | 2025-11 | 2026-07 |
|---|---|---|---|---|---|
outputSchema 的 type 为 array 的 Tool | — | — | ✗ | ✗ | ✓ |
structuredContent 为数组的 CallToolResult | — | — | ✗ | ✗ | ✓ |
不带 resultType 的 CallToolResult | ✓ | ✓ | ✓ | ✓ | ✗ |
不带 ttlMs、cacheScope、resultType 的 ListToolsResult | ✓ | ✓ | ✓ | ✓ | ✗ |
不带 params._meta 的 tools/call 请求 | ✓ | ✓ | ✓ | ✓ | ✗ |
tools/call 返回 input_required 结果 | ✗ | ✗ | ✗ | ✗ | ✓ |
各行的构造:第 1、2、6 行是 2026-07-28 的官方示例原样使用;第 3、4、5 行是官方示例删去所列字段。所有未改动的示例都通过 2026-07-28 schema 的校验:共校验 12 个定义下的 22 个工具相关示例,无一无效。
- 第 1、2 行。 数组形态的
outputSchema或structuredContent只在 2026-07-28 有效;对 2025-06-18 和 2025-11-25 会被拒绝,因为两者都要求对象。返回这类形态之前,先确认客户端用的是哪个修订版。 - 第 3 至 5 行。 2026-07-28 要求
resultType、带cacheScope的ttlMs以及请求的_meta。按 2025-11-25 及更早修订版写的代码,补上它们之前通不过校验。 - 第 6 行。
input_required结果在 2026-07-28 之前不是合法的tools/call结果:它没有content。 - 这种方法的局限。 JSON Schema 校验只检查形状。某一列没有
✗,不代表行为兼容。
有些变化只写在工具页的正文里。下表检查每个修订版的页面是否出现引入该规则的句子:
| 规范文本是否写到 | 2024-11 | 2025-03 | 2025-06 | 2025-11 | 2026-07 |
|---|---|---|---|---|---|
| 工具名称指引(1–128 字符、限定字符集) | — | — | — | ✓ | ✓ |
| 输入校验错误作为工具执行错误返回 | — | — | — | ✓ | ✓ |
tools/list 应保持确定顺序 | — | — | — | — | ✓ |
tools/list 不得因连接而异 | — | — | — | — | ✓ |
x-mcp-header 把参数镜像到 HTTP 头 | — | — | — | — | ✓ |
| 用显式句柄实现有状态工具 | — | — | — | — | ✓ |
列表变更经 subscriptions/listen 送达 | — | — | — | — | ✓ |
五、错误:三条通道
一次失败的调用可能出现在不同位置。选错通道,要么让模型看不到本可修正的错误,要么让客户端放弃本该重试的错误。规范这样划分:协议错误用于模型不太可能自行修复的请求结构问题(未知工具、不满足 CallToolRequest schema 的格式错误请求、服务器错误);工具执行错误用于模型可据以自我纠正的可操作反馈(API 失败、输入校验错误如日期格式不对或数值越界、业务逻辑错误)。客户端可以把协议错误交给模型,应当把执行错误交给模型。
| 情形 | 通道与收到的内容 | 怎么处理 |
|---|---|---|
请求不满足 CallToolRequest(缺 name、arguments 不是对象) | JSON-RPC 错误 -32602(InvalidParamsError) | 修客户端;原样重试不可能成功 |
| 未知工具 | 规范示例里是 JSON-RPC 错误 -32602,消息形如 Unknown tool: …(官方 SDK 改为返回 isError 结果,见第六节) | 刷新 tools/list;两种形态都要处理 |
| 参数类型错误、越界或格式不对 | 工具执行错误:result.isError: true,文本写明字段与合法取值 | 把文本交给模型,用修正后的参数重试 |
| 工具背后的 API 失败,或业务规则拒绝 | 工具执行错误:result.isError: true 并附说明 | 让模型决定:重试、改计划或告知用户 |
| 服务器自身故障 | JSON-RPC 错误 -32603(InternalError) | 退避重试;不要把原始错误当作指令回灌 |
请求头与正文不一致(Mcp-Method、Mcp-Name、Mcp-Param-*)或缺失 | HTTP 400 加 JSON-RPC 错误 -32020(HeaderMismatchError) | 客户端或网关的问题;修正请求头 |
| 协议版本未知或不被支持 | HTTP 400 加 -32022(UnsupportedProtocolVersionError);data.requested、data.supported | 从 supported 里选一个版本重试 |
| 调用需要客户端未声明的能力 | HTTP 400 加 -32021(MissingRequiredClientCapabilityError);data.requiredCapabilities | 声明该能力,或不再使用这个工具 |
| RPC 方法不存在 | HTTP 404 加 -32601(MethodNotFoundError) | 服务器没有实现该方法 |
| 服务器要先拿到更多输入才能完成 | 不是错误:resultType: "input_required" 的结果,带 inputRequests,可能还有 requestState | 回答这些请求,带上 inputResponses、requestState 和新的 JSON-RPC id 重试 |
表中的错误码和三条 HTTP 400 规则,已对照 2026-07-28 的 schema.json 和固定提交里的 Streamable HTTP 页面核对。
六、官方 Python SDK 2.3.0 实际返回什么
参考服务器为每种结果形态定义一个工具,使用官方 SDK(pip install mcp==2.3.0):
tools_ref_server.py
"""Official Python SDK (mcp==2.3.0) server with one tool per result shape, http://127.0.0.1:8200/mcp, no auth."""
from typing import Annotated
from mcp.server.mcpserver import MCPServer
from pydantic import BaseModel, Field
server = MCPServer("tools-ref")
class Weather(BaseModel):
temperature: float
conditions: str
class User(BaseModel):
id: str
name: str
@server.tool()
def get_weather(location: Annotated[str, Field(min_length=2)]) -> Weather:
"""Return fixed weather for a location."""
return Weather(temperature=22.5, conditions="Partly cloudy")
@server.tool()
def list_users() -> list[User]:
"""Return two users as an array."""
return [User(id="1", name="Alice"), User(id="2", name="Bob")]
@server.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
@server.tool()
def get_current_time() -> str:
"""Take no arguments."""
return "2026-10-26T00:00:00Z"
@server.tool()
def explode() -> str:
"""Always raise."""
raise RuntimeError("boom")
if __name__ == "__main__":
server.run("streamable-http", port=8200)用 2026-07-28 的请求格式经 Streamable HTTP 发出 12 个请求,每个响应都对 2026-07-28 schema.json 里对应的定义做了校验(12 个全部有效;定义名写在每条记录的末尾):
| 运行 | 请求 | SDK 返回了什么 | 对照规范 |
|---|---|---|---|
list | tools/list | ttlMs: 0、cacheScope: "private"、resultType: "complete",5 个工具,每个都带 outputSchema | 有效。SDK 用最保守的值填满必填的缓存提示 |
weather-ok | 用合法地点调用 get_weather | 一个装着 JSON 的文本块,structuredContent 是对象 | 符合输出契约和文本副本的建议 |
sum-ok | add,参数 1、2 | structuredContent: {"result": 3},文本块是 3 | 有效,但返回值被包进对象,文本块也不是 structuredContent 的序列化 |
users-ok | list_users(返回数组) | structuredContent: {"result": [...]} 和两个文本块(每个元素一个) | 同样被包装;SDK 没有使用 2026-07-28 允许数组形态 structuredContent 的放宽 |
time-no-args | get_current_time(无参数) | inputSchema 是 type: "object",没有 additionalProperties: false | 有效;不是推荐的封闭写法 |
bad-type | add,a 传 "x" | isError: true,pydantic 的消息,带 errors.pydantic.dev 链接 | 符合规范:输入校验属于执行错误。消息冗长且带第三方链接 |
bad-constraint | 用一个字符的地点调用 get_weather(minLength 为 2) | isError: true,同样风格的消息 | 同上 |
missing-argument | 不带 location 调用 get_weather | isError: true,同样风格的消息 | 同上 |
handler-raises | 会抛异常的 explode | isError: true,文本 Error executing tool explode | 有效。不泄露内部信息,但模型无法据此自我纠正;原因要记在服务器日志里 |
unknown-tool | 不存在的名字 | isError: true,文本 Unknown tool: nope | 规范把未知工具列在协议错误下(示例为 -32602);SDK 返回执行错误。客户端两种形态都要处理 |
missing-name | 不带 name 的 tools/call | HTTP 400,JSON-RPC 错误 -32602 | 符合规范,且通过 InvalidParamsError 校验 |
arguments-array | arguments 是数组 | HTTP 400,JSON-RPC 错误 -32602 | 同上 |
12 次运行的原始记录
list HTTP 200 result ttlMs=0 cacheScope=private resultType=complete tools=5 | ListToolsResultResponse valid
weather-ok HTTP 200 result 1 text blocks, structuredContent {"temperature":22.5,"conditions":"Partly cloudy"} | CallToolResultResponse valid
sum-ok HTTP 200 result 1 text blocks, structuredContent {"result":3} | CallToolResultResponse valid
users-ok HTTP 200 result 2 text blocks, structuredContent {"result":[{"id":"1","name":"Alice"},{"id":"2","name":"Bob"}]} | CallToolResultResponse valid
time-no-args HTTP 200 result 1 text blocks, structuredContent {"result":"2026-10-26T00:00:00Z"} | CallToolResultResponse valid
bad-type HTTP 200 isError text 'Error executing tool add: 1 validation error for addArguments\n' link=errors.pydantic.dev | CallToolResultResponse valid
bad-constraint HTTP 200 isError text 'Error executing tool get_weather: 1 validation error for get_w' link=errors.pydantic.dev | CallToolResultResponse valid
missing-argument HTTP 200 isError text 'Error executing tool get_weather: 1 validation error for get_w' link=errors.pydantic.dev | CallToolResultResponse valid
handler-raises HTTP 200 isError text 'Error executing tool explode' | CallToolResultResponse valid
unknown-tool HTTP 200 isError text 'Unknown tool: nope' | CallToolResultResponse valid
missing-name HTTP 400 error code -32602 'Invalid request parameters' | JSONRPCErrorResponse valid
arguments-array HTTP 400 error code -32602 'Invalid request parameters' | JSONRPCErrorResponse valid七、失效模式与恢复
| 现象 | 原因 | 怎么发现 | 恢复 |
|---|---|---|---|
| 服务器上有这个工具,客户端的列表里却没有 | x-mcp-header 违反约束。Streamable HTTP 客户端必须把该工具从 tools/list 里剔除,并应当记录带原因的警告 | 运行检查脚本(第八节);看客户端的警告日志 | 修正注解:非空的 HTTP token、忽略大小写后唯一、只用于 integer、string、boolean 属性、只经 properties 键可达 |
| 模型调用了另一个服务器的工具 | 被聚合的两个服务器暴露了同名工具 | 跨服务器列出名称 | 聚合层加服务器标识前缀;不要用服务器 name |
| 两个客户端拿到的列表不同,或同一客户端的列表在会话中途变化 | 列表依赖连接状态。服务器不得让列表因连接或其他请求的副作用而变化 | 用相同凭据在两条连接上调用 tools/list 并做 diff | 只根据请求的授权计算列表。列表可以随授权变化 |
| 缓存的列表被展示给了别的用户 | 按权限范围过滤过的列表被标成 cacheScope: "public" | 对比两个权限范围不同的令牌拿到的列表 | 过滤过的列表用 "private";只有对所有用户都相同时才用 "public" |
| 客户端一直显示过期的列表 | 服务器改了工具,但 notifications/tools/list_changed 只会发给以 toolsListChanged: true 打开了 subscriptions/listen 的客户端。服务器可以只给 ttlMs 而不声明 listChanged,这时客户端只能靠 TTL | 确认流已打开;对比 ttlMs 与列表变化的频率 | 声明 listChanged 并发送通知;列表常变时把 ttlMs 设短,或设为 0 |
| 重启后提示词缓存一直不命中 | 列表顺序不稳定。服务器应当以确定的顺序返回工具 | 对两次 tools/list 响应做 diff | 返回前按名称排序 |
| 面向旧修订版的客户端在工具结果上出错 | 把数组形态的 structuredContent 或 outputSchema 发给了期待对象的客户端(矩阵第 1、2 行) | 用旧修订版的 schema.json 校验你的结果 | 把值包进对象,或只对 2026-07-28 的客户端返回新形态 |
客户端拒收 structuredContent | 它不符合声明的 outputSchema;服务器必须符合 | 带 --call 运行检查脚本 | 返回前在服务器内校验结果 |
| 调用报“未知句柄” | 有状态工具的句柄过期了。用过期或未知句柄的调用应当返回说明这一点的执行错误 | 跨过保留期调用两次 | 在创建工具的描述里写明保留期;返回 isError 结果,告诉模型重新创建句柄 |
| 模型从来看不到错误 | 客户端把 isError 当成功,或只写了日志 | 发一个错误参数,看模型的输入 | 客户端应当把执行错误交给模型;返回模型能据以行动的文本 |
| 代理日志里出现敏感值 | 密码或令牌被 x-mcp-header 镜像;头的值对中间设备可见 | 在 inputSchema 里搜索 x-mcp-header | 不要给敏感参数加注解(不应) |
八、检查你自己的工具定义
lint_tools.py 从 Streamable HTTP 端点取 tools/list(或读 JSON 文件),检查上面的规则。加 --call 时还会调用一个工具,并对照该工具的 outputSchema 检查结果。schema 检查需要 jsonschema,缺包时只给出警告。
lint_tools.py
#!/usr/bin/env python3
"""Lint the tool definitions of an MCP server against spec 2026-07-28, and optionally call one tool.
Usage: lint_tools.py SOURCE [--call NAME [--args JSON]] [--token T]
SOURCE: URL of a Streamable HTTP endpoint, or a JSON file ("-" = stdin) holding a tools/list response,
its result, or a tools array. The list result itself is checked too (resultType, ttlMs, cacheScope) unless the
file holds a bare tools array. Schema checks need `pip install jsonschema` (skipped with a WARN without it).
Levels: FAIL = a MUST of the spec is violated, WARN = a SHOULD or a recommendation is not followed.
Exit code: 0 no FAIL, 1 at least one FAIL, 2 the source cannot be read.
"""
import argparse
import http.client
import json
import re
import sys
from urllib.parse import urlsplit
try:
import jsonschema
except ImportError:
jsonschema = None
VERSION = "2026-07-28"
NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
TCHAR_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
HEADER_TYPES = {"integer", "string", "boolean"}
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}
def report(name, ok, detail, level="FAIL"):
status = "PASS" if ok else level
counts[status] += 1
print("%s %s: %s" % (status, name, detail))
def rpc(url, method, params=None, token=None):
"""POST one 2026-07-28 request and return (HTTP status, JSON-RPC message or None)."""
parts = urlsplit(url)
cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection
meta = {"io.modelcontextprotocol/protocolVersion": VERSION,
"io.modelcontextprotocol/clientInfo": {"name": "lint-tools", "version": "0"},
"io.modelcontextprotocol/clientCapabilities": {}}
headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json",
"MCP-Protocol-Version": VERSION, "Mcp-Method": method}
if params and "name" in params:
headers["Mcp-Name"] = params["name"]
if token:
headers["Authorization"] = "Bearer " + token
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": {**(params or {}), "_meta": meta}})
conn = cls(parts.netloc, timeout=15)
conn.request("POST", parts.path or "/", body, headers)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", "replace")
conn.close()
try:
return resp.status, json.loads(raw)
except ValueError:
for line in reversed(raw.splitlines()): # SSE: the last data line holding a response
if line.startswith("data:"):
try:
return resp.status, json.loads(line[5:].strip())
except ValueError:
continue
return resp.status, None
def load_tools(source, token):
"""Return (tools, envelope): envelope is the first tools/list result, None for a bare tools array."""
if source.startswith(("http://", "https://")):
tools, cursor, envelope = [], None, None
for _ in range(50):
status, msg = rpc(source, "tools/list", {"cursor": cursor} if cursor else None, token)
result = (msg or {}).get("result")
if status != 200 or not isinstance(result, dict) or not isinstance(result.get("tools"), list):
raise ValueError("tools/list failed: HTTP %s, %s" % (status, json.dumps(msg)[:200]))
tools += result["tools"]
envelope = envelope or result
cursor = result.get("nextCursor")
if not cursor:
return tools, envelope
raise ValueError("tools/list still paginating after 50 pages")
data = json.load(sys.stdin if source == "-" else open(source, encoding="utf-8"))
data = data.get("result", data) if isinstance(data, dict) else data
tools = data.get("tools") if isinstance(data, dict) else data
if not isinstance(tools, list):
raise ValueError("no tools array found in %s" % source)
return tools, data if isinstance(data, dict) else None
def dialect(schema):
uri = schema.get("$schema", "") if isinstance(schema, dict) else ""
return jsonschema.Draft7Validator if "draft-07" in uri else jsonschema.Draft202012Validator
def schema_problem(schema):
try:
dialect(schema).check_schema(schema)
except jsonschema.SchemaError as exc:
return exc.message[:100]
return None
def header_annotations(schema):
"""Yield (name, value, chain, reachable) for every x-mcp-header in a schema; reachable means a pure `properties` chain."""
def walk(node, chain, reachable):
if not isinstance(node, dict):
return
if "x-mcp-header" in node:
yield node, chain, reachable
for key, value in node.items():
if key == "properties" and isinstance(value, dict):
for prop, sub in value.items():
yield from walk(sub, chain + [prop], reachable)
elif key not in ("x-mcp-header", "enum", "const", "default", "examples"):
for sub in (value if isinstance(value, list) else
list(value.values()) if key in ("$defs", "definitions", "patternProperties") else [value]):
yield from walk(sub, chain + [key], False)
yield from walk(schema, [], True)
def header_problems(schema):
problems, seen = [], {}
for node, chain, reachable in header_annotations(schema):
value, where = node["x-mcp-header"], "/".join(chain) or "(root)"
if not reachable:
problems.append("%s: not statically reachable" % where)
if not isinstance(value, str) or not TCHAR_RE.match(value):
problems.append("%s: %r is not a non-empty HTTP token" % (where, value))
continue
if value.lower() in seen:
problems.append("%s: duplicates %s (case-insensitive)" % (where, seen[value.lower()]))
seen[value.lower()] = where
kind = node.get("type")
kinds = set(kind) if isinstance(kind, list) else {kind}
if not kinds <= HEADER_TYPES:
problems.append("%s: type %s is not integer, string or boolean" % (where, kind))
return problems
def lint_envelope(result):
"""tools/list results of 2026-07-28 MUST carry resultType, and caching hints with ttlMs >= 0."""
if result is None:
return
ttl, scope = result.get("ttlMs"), result.get("cacheScope")
problems = []
if not isinstance(result.get("resultType"), str):
problems.append("resultType is missing")
if not isinstance(ttl, int) or isinstance(ttl, bool) or ttl < 0:
problems.append("ttlMs is %r, want an integer >= 0" % ttl)
if scope not in ("public", "private"):
problems.append("cacheScope is %r, want 'public' or 'private'" % scope)
report("list-envelope", not problems, "; ".join(problems) if problems
else "resultType=%s ttlMs=%d cacheScope=%s" % (result["resultType"], ttl, scope))
def lint(tools, envelope=None):
lint_envelope(envelope)
named = [t for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)]
report("name-present", len(named) == len(tools), "%d of %d tools have a string name" % (len(named), len(tools)))
bad = [t["name"] for t in named if not NAME_RE.match(t["name"])]
report("name-charset", not bad, "bad names: %s" % bad if bad else "%d names match [A-Za-z0-9_.-]{1,128}" % len(named),
"WARN")
names = [t["name"] for t in named]
dup = sorted({n for n in names if names.count(n) > 1})
report("name-unique", not dup, "duplicates: %s" % dup if dup else "no duplicates", "WARN")
root = [t["name"] for t in named if not (isinstance(t.get("inputSchema"), dict)
and t["inputSchema"].get("type") == "object")]
report("input-root-object", not root, "inputSchema is not an object schema with type=object: %s" % root
if root else "every inputSchema is an object schema with type=object")
open_schema = [t["name"] for t in named if isinstance(t.get("inputSchema"), dict)
and t["inputSchema"].get("type") == "object" and not t["inputSchema"].get("properties")
and t["inputSchema"].get("additionalProperties") is not False]
report("no-params-closed", not open_schema,
"no-parameter tools accept any object, add additionalProperties:false: %s" % open_schema
if open_schema else "no-parameter tools are closed", "WARN")
hdr = ["%s %s" % (t["name"], p) for t in named if isinstance(t.get("inputSchema"), dict)
for p in header_problems(t["inputSchema"])]
report("x-mcp-header", not hdr, "; ".join(hdr) if hdr else "annotations satisfy the constraints (or none used)")
if jsonschema is None:
report("schema-valid", False, "skipped, jsonschema is not installed", "WARN")
return
problems = ["%s.%s: %s" % (t["name"], k, p) for t in named for k in ("inputSchema", "outputSchema")
if isinstance(t.get(k), dict) and (p := schema_problem(t[k]))]
report("schema-valid", not problems, "; ".join(problems) if problems else "inputSchema / outputSchema are valid JSON Schema")
def call(url, tools, name, args, token):
status, msg = rpc(url, "tools/call", {"name": name, "arguments": args}, token)
if not isinstance(msg, dict):
report("call", False, "HTTP %s without a JSON-RPC message" % status)
return
if "error" in msg:
print("INFO call %s: protocol error, HTTP %s, code %s: %s" % (name, status, msg["error"].get("code"),
msg["error"].get("message")))
return
result = msg.get("result") or {}
if result.get("resultType") == "input_required":
print("INFO call %s: input_required, retry with inputResponses (nothing more to check)" % name)
return
report("call-result-type", isinstance(result.get("resultType"), str), "resultType=%s" % result.get("resultType")
if isinstance(result.get("resultType"), str) else "resultType is missing")
if result.get("isError"):
text = " ".join(c.get("text", "") for c in result.get("content", []) if c.get("type") == "text")
print("INFO call %s: execution error (isError), HTTP %s: %s" % (name, status, text[:100].replace("\n", " ")))
return
print("INFO call %s: result, HTTP %s, %d content blocks, structuredContent %s" % (
name, status, len(result.get("content", [])), "present" if "structuredContent" in result else "absent"))
tool = next((t for t in tools if isinstance(t, dict) and t.get("name") == name), {})
structured = result.get("structuredContent")
if tool.get("outputSchema"):
report("structured-present", "structuredContent" in result, "the tool declares an outputSchema")
if jsonschema and "structuredContent" in result:
errs = list(dialect(tool["outputSchema"])(tool["outputSchema"]).iter_errors(structured))
report("structured-conforms", not errs, errs[0].message[:100] if errs else "structuredContent matches outputSchema")
if "structuredContent" in result:
copies = []
for c in result.get("content", []):
try:
copies.append(c.get("type") == "text" and json.loads(c["text"]) == structured)
except (ValueError, KeyError):
copies.append(False)
report("structured-text-copy", any(copies), "a text block holds the serialized structuredContent", "WARN")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("source")
ap.add_argument("--call", help="name of a tool to call after the lint")
ap.add_argument("--args", default="{}", help="JSON arguments for --call")
ap.add_argument("--token", help="bearer token for the endpoint")
args = ap.parse_args()
if args.call and not args.source.startswith(("http://", "https://")):
ap.error("--call needs a URL as SOURCE")
try:
tools, envelope = load_tools(args.source, args.token)
arguments = json.loads(args.args)
except (OSError, ValueError, http.client.HTTPException) as exc:
print("ERROR %s" % exc)
return 2
print("INFO %d tools" % len(tools))
lint(tools, envelope)
if args.call:
try:
call(args.source, tools, args.call, arguments, args.token)
except (OSError, http.client.HTTPException) as exc:
print("ERROR %s" % exc)
return 2
print("%d pass, %d warn, %d fail" % (counts["PASS"], counts["WARN"], counts["FAIL"]))
return 1 if counts["FAIL"] else 0
if __name__ == "__main__":
sys.exit(main())pip install jsonschema
python3 lint_tools.py http://127.0.0.1:8200/mcp --call add --args '{"a":1,"b":2}'
python3 lint_tools.py tools.json # 保存下来的 tools/list 响应、其 result,或工具数组FAIL 表示违反了 MUST;WARN 表示没做到 SHOULD 或推荐写法。退出码 0 表示没有 FAIL,1 表示至少一个 FAIL,2 表示读不到来源。请对 URL 或保存的响应运行,这样列表结果会和定义一起被检查;裸工具数组没有结果可查。
| 检查 | 级别 | 规则 |
|---|---|---|
list-envelope | FAIL | tools/list 结果带 resultType、>= 0 的整数 ttlMs,以及取值为 public 或 private 的 cacheScope(来源是裸工具数组时跳过) |
name-present | FAIL | 每个工具都有字符串类型的 name |
name-charset | WARN | 1 到 128 个字符,取自字母、数字、_、-、. |
name-unique | WARN | 名称在同一服务器内唯一 |
input-root-object | FAIL | inputSchema 是根部 type: "object" 的 JSON Schema 对象 |
no-params-closed | WARN | 无参数工具使用 additionalProperties: false |
x-mcp-header | FAIL | 非空 HTTP token、忽略大小写后唯一、原始类型、只经 properties 可达 |
schema-valid | FAIL | inputSchema 与 outputSchema 按各自方言是合法的 JSON Schema(需要 jsonschema) |
call-result-type | FAIL | 带 --call:结果带 resultType;input_required 结果只报告,不再检查 |
structured-present | FAIL | 带 --call:声明了 outputSchema 的工具必须返回 structuredContent |
structured-conforms | FAIL | 带 --call:structuredContent 符合 outputSchema |
structured-text-copy | WARN | 带 --call:有一个文本块装着序列化的 structuredContent |
对第六节 SDK 服务器的运行记录:
INFO 5 tools
PASS list-envelope: resultType=complete ttlMs=0 cacheScope=private
PASS name-present: 5 of 5 tools have a string name
PASS name-charset: 5 names match [A-Za-z0-9_.-]{1,128}
PASS name-unique: no duplicates
PASS input-root-object: every inputSchema is an object schema with type=object
WARN no-params-closed: no-parameter tools accept any object, add additionalProperties:false: ['list_users', 'get_current_time', 'explode']
PASS x-mcp-header: annotations satisfy the constraints (or none used)
PASS schema-valid: inputSchema / outputSchema are valid JSON Schema
PASS call-result-type: resultType=complete
INFO call add: result, HTTP 200, 1 content blocks, structuredContent present
PASS structured-present: the tool declares an outputSchema
PASS structured-conforms: structuredContent matches outputSchema
WARN structured-text-copy: a text block holds the serialized structuredContent
10 pass, 2 warn, 0 fail装了 jsonschema 时,对一组合法的定义(带缓存提示的列表结果、嵌套的 x-mcp-header、数组 outputSchema、显式 draft-07 $schema、根部 oneOf),脚本输出 8 pass, 0 warn, 0 fail。对一组每种缺陷各出现一次的定义(一个不带缓存提示的列表结果、一个错误名称、一个重复名称、字符串根、非法 schema,以及六种 x-mcp-header 违规),输出 2 pass, 2 warn, 4 fail,并逐个点名缺陷。
九、上线前核对
- 名称符合
[A-Za-z0-9_.-]{1,128}且在服务器内唯一;聚合层加前缀。 inputSchema根部是type: "object";无参数工具用additionalProperties: false。- 校验失败、API 失败和业务规则一律返回
isError: true,文本写明字段与合法取值。只有格式错误的请求才是协议错误。 - 声明了
outputSchema就要保证每个结果都符合它,并用一个文本块携带序列化内容。 tools/list对所有连接一致、有序,并带ttlMs与cacheScope;按权限过滤过的列表用"private"。- 非对象的结构化输出只发给 2026-07-28 的客户端,否则先包装。
- 敏感参数不用
x-mcp-header镜像;检查脚本在你的工具列表上通过。 - 更换 SDK、网关或所服务的修订版之后,重新运行检查脚本。
版本与验证说明
表格由官方 schema.json 生成,官方示例逐个对 schema 校验,运行时行为来自对官方 Python SDK 2.3.0(mcp==2.3.0)的实际运行。规范文字与 schema 都取自官方仓库的同一个固定提交(dae694f6,含 2026-07-28 修订版及之前四个修订版)。表格用 jsonschema 4.26.0 生成并核对:schema 声明 draft-07 的修订版用 draft-07 校验器,其余用 2020-12 校验器。SDK 的运行使用 mcp==2.3.0、Python 3.14(macOS)。官方文本或 SDK 变化后需要重跑。
来源
- 官方 2026-07-28 页面:工具、缓存、分页、Streamable HTTP 中“由工具参数生成的自定义头”与 schema 参考
- 各修订版的
schema.json与官方示例,见官方仓库 - 站内的 MCP 服务器最佳实践