MCP 工具详解:定义、结果、错误与跨版本差异

MCP 工具详解:定义、结果、错误与跨版本差异

工具是服务器公开、由模型调用的一个具名操作。一次调用经过五步:定义、列表、调用、结果、失败。下面按 2026-07-28 修订版走完这条路径,并逐个修订版说明自 2024-11-05 以来改了什么,同时说明错误该走哪条通道。

怎样把工具设计得让模型好用,见 MCP 服务器最佳实践。要在运行中的服务器上列出、调用工具,并区分连接失败与工具失败,继续看 MCP Inspector 测试指南。规范的约束文本见官方 工具页。

一、一次工具调用的路径

步骤消息要点章节
1能力声明支持工具的服务器必须声明 tools 能力;listChanged 表示列表变化时是否通知客户端七
2tools/list支持分页(cursor、nextCursor)与缓存;结果带 ttlMs 和 cacheScope三、七
3tools/callname 与 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-112025-032025-062025-112026-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-112025-032025-062025-112026-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-112025-032025-062025-112026-07
schema.json 的 JSON Schema 方言draft-07draft-07draft-072020-122020-12
content 块的 type 取值text, image, resourcetext, image, audio, resourcetext, image, audio, resource_link, resourcetext, image, audio, resource_link, resourcetext, image, audio, resource_link, resource
structuredContent——objectobject任意 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-RPC id 必须不同。带 inputResponses 或 requestState 的重试结果不得缓存。

四、同一条消息,五份 schema

对某个修订版有效的消息,在其他修订版里还成立吗?每一行都对五个 schema.json 分别校验。✓ 表示有效,✗ 表示被拒绝,— 表示该修订版没有定义这一行所涉及的属性,校验说明不了什么。

消息2024-112025-032025-062025-112026-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-112025-032025-062025-112026-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 返回了什么对照规范
listtools/listttlMs: 0、cacheScope: "private"、resultType: "complete",5 个工具,每个都带 outputSchema有效。SDK 用最保守的值填满必填的缓存提示
weather-ok用合法地点调用 get_weather一个装着 JSON 的文本块,structuredContent 是对象符合输出契约和文本副本的建议
sum-okadd,参数 1、2structuredContent: {"result": 3},文本块是 3有效,但返回值被包进对象,文本块也不是 structuredContent 的序列化
users-oklist_users(返回数组)structuredContent: {"result": [...]} 和两个文本块(每个元素一个)同样被包装;SDK 没有使用 2026-07-28 允许数组形态 structuredContent 的放宽
time-no-argsget_current_time(无参数)inputSchema 是 type: "object",没有 additionalProperties: false有效;不是推荐的封闭写法
bad-typeadd,a 传 "x"isError: true,pydantic 的消息,带 errors.pydantic.dev 链接符合规范:输入校验属于执行错误。消息冗长且带第三方链接
bad-constraint用一个字符的地点调用 get_weather(minLength 为 2)isError: true,同样风格的消息同上
missing-argument不带 location 调用 get_weatherisError: true,同样风格的消息同上
handler-raises会抛异常的 explodeisError: true,文本 Error executing tool explode有效。不泄露内部信息,但模型无法据此自我纠正;原因要记在服务器日志里
unknown-tool不存在的名字isError: true,文本 Unknown tool: nope规范把未知工具列在协议错误下(示例为 -32602);SDK 返回执行错误。客户端两种形态都要处理
missing-name不带 name 的 tools/callHTTP 400,JSON-RPC 错误 -32602符合规范,且通过 InvalidParamsError 校验
arguments-arrayarguments 是数组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-envelopeFAILtools/list 结果带 resultType、>= 0 的整数 ttlMs,以及取值为 public 或 private 的 cacheScope(来源是裸工具数组时跳过)
name-presentFAIL每个工具都有字符串类型的 name
name-charsetWARN1 到 128 个字符,取自字母、数字、_、-、.
name-uniqueWARN名称在同一服务器内唯一
input-root-objectFAILinputSchema 是根部 type: "object" 的 JSON Schema 对象
no-params-closedWARN无参数工具使用 additionalProperties: false
x-mcp-headerFAIL非空 HTTP token、忽略大小写后唯一、原始类型、只经 properties 可达
schema-validFAILinputSchema 与 outputSchema 按各自方言是合法的 JSON Schema(需要 jsonschema)
call-result-typeFAIL带 --call:结果带 resultType;input_required 结果只报告,不再检查
structured-presentFAIL带 --call:声明了 outputSchema 的工具必须返回 structuredContent
structured-conformsFAIL带 --call:structuredContent 符合 outputSchema
structured-text-copyWARN带 --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 变化后需要重跑。

来源