怎样写出好用的 MCP 工具:名称、描述与参数

怎样写出好用的 MCP 工具:名称、描述与参数

模型靠读你放进 tools/list 的文字来挑工具。规范定下了工具定义是什么,以及名称、schema、错误的几条规则,但没有说怎样写才能让模型选对。下面把三层东西分开,免得混为一谈:规范要求什么,schema 的默认值意味着什么,以及来自下面脚本、并非规范文字的设计经验规则。最后给出一个可以直接跑在你自己列表上的检查脚本,和一个用你自己的任务给工具排名的选择测试。

工具调用在协议层面的部分(消息、各修订版的 schema、错误通道、SDK 行为)见 MCP 工具详解。远程服务器必须通过的规则见 MCP 服务器最佳实践。规范原文见 工具。

一、模型读什么,哪些由你控制

规范没有规定客户端把什么交给模型,而它论证“保持确定顺序”时说的是“工具被放进模型上下文时”的情形。请假定列表里的任何内容都可能到达模型。下表写明每个字段该写什么,以及规则出处。

字段该写什么依据
name动词加对象,统一一种命名风格,在服务器内唯一;1 到 128 个字符,取自 A-Z a-z 0-9 _ - .,区分大小写规范“Tool Names”(SHOULD)
title给人看的标签。客户端用它做展示,不要指望模型能看到规范:“Optional human-readable name of the tool for display purposes”
description工具做什么、何时用、何时该改用邻近的工具。schema 把它标为可选;检查脚本在不足 8 个词时给警告规范:“Human-readable description of functionality”;词数是经验规则
inputSchema合法的 JSON Schema 对象,不得为 null。每个参数都写描述或给 enum;即使为空也声明 required。无参数的工具用 { "type": "object", "additionalProperties": false }规范“Tool”(对象为 MUST;封闭写法是推荐)
outputSchema、structuredContent结果有结构就声明 outputSchema。结果 MUST 符合它;同时把序列化后的 JSON 放进文本块,兼容旧客户端规范“Output Schema”“Structured Content”
annotations如实声明 readOnlyHint 与 destructiveHint。省略的提示按 schema 默认值:readOnlyHint 为 false,destructiveHint 为 true,idempotentHint 为 false,openWorldHint 为 true。除非来自受信任的服务器,客户端 MUST 把注解视为不可信官方 schema.json;规范“Tool”
isError 结果的文字对模型能自行修正的失败,写明错在哪、什么才合法。客户端 SHOULD 把执行错误展示给模型规范“Error Handling”
tools/list 的顺序与体积按确定顺序返回工具。检查脚本会打印列表的字节数规范“Capabilities”(顺序为 SHOULD)

二、哪些决策会改变模型选哪个工具

每一行是你写定义时要做的一个选择。最后一列写出第四节脚本中覆盖它的检查,或注明没有检查。

决策选择原因检查项
一个带 action 参数的工具,还是每种操作一个工具效果不同(读、写、删)时,每种操作一个工具注解描述的是整个工具;既能读又能删的工具无法如实声明 readOnlyHint 或 destructiveHintannotations
简短的通用名称,还是对象在前的名称tickets_search,而不是 search名称在服务器内必须唯一;合并多个服务器的客户端可能遇到两个 search,规范要求它们用前缀消歧。名称里已经有对象,没有前缀也有意义name-generic、name-unique
取值有限的参数用自由文本还是 enumenum模型在定义里就能看到允许的取值,不必去猜params-described
哪些参数必填声明 required,全部可选时写空列表不声明,定义就没有说明调用需要什么params-required
对象类型的参数给它写 properties光秃秃的 {"type": "object"} 没有告诉模型内容是什么params-opaque
怎样区分两个相近的工具在各自的描述里写明何时用它、何时改用另一个;用你的用户用的词第六节的草稿在一个任务上失败,原因是邻居工具的描述里有任务的词,而正确的工具里没有near-duplicate、选择测试
跨调用的状态由创建工具返回显式句柄,之后的调用把句柄作为参数;把保留期写进创建工具的描述MCP 没有协议层会话。规范:对过期或未知的句柄,应返回说明这一点的执行错误无
破坏性操作设置 destructiveHint;预期客户端会询问用户规范要求有人能够拒绝工具调用;提示不是安全控制,因为客户端必须把它视为不可信annotations
可能很大的结果加筛选条件和上限,并把上限写进描述(at most 20 per call)规范没有给出体积;上限应由你真实调用的返回量决定无
模型能自行修正的失败返回 isError: true,写明规则与合法形式(日期应在未来、取值超出范围)规范:执行错误“包含语言模型可用来自我纠正的可操作反馈”无;见工具详解第五节

三、同一套工具的三版草稿

同样八个意图(查看工单、搜索、关闭、删除、生成周报、查找用户、评论、创建)写了三遍。样例是手写的。

版本名称描述参数注解
confusing-tools.jsonget_data、fetch_data、query、search、find、run、create、update、delete两到六个词(“Update ticket data.”)光秃秃的字符串与对象,没有 required无
draft-tools.jsontickets_get、tickets_search、tickets_create、tickets_update_status、tickets_add_comment、tickets_delete、users_find、reports_weekly_summary一两句话,写明何时使用有描述,状态用 enum,声明了 required已声明
clear-tools.json与草稿相同草稿中的五条描述,按第六节的失败改了措辞相同相同
confusing-tools.json
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "ttlMs": 0, "cacheScope": "public", "tools": [
  {"name": "get_data", "description": "Get ticket data by id.", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}}},
  {"name": "fetch_data", "description": "Fetch ticket data.", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}}},
  {"name": "query", "description": "Query ticket data with a filter.", "inputSchema": {"type": "object", "properties": {"filter": {"type": "object"}}}},
  {"name": "search", "description": "Search ticket data.", "inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}},
  {"name": "find", "description": "Find ticket or user data.", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}},
  {"name": "run", "description": "Run a report job.", "inputSchema": {"type": "object", "properties": {"job": {"type": "string"}}}},
  {"name": "create", "description": "Create a new ticket.", "inputSchema": {"type": "object", "properties": {"data": {"type": "object"}}}},
  {"name": "update", "description": "Update ticket data.", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}, "data": {"type": "object"}}}},
  {"name": "delete", "description": "Delete ticket data.", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}}}
]}}
draft-tools.json
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "ttlMs": 0, "cacheScope": "public", "tools": [
  {"name": "tickets_get", "description": "Return one support ticket (title, status, assignee, comments) when you already know its numeric id. Use tickets_search when the id is not known.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}}, "required": ["ticket_id"]}, "annotations": {"readOnlyHint": true}},
  {"name": "tickets_search", "description": "List support tickets that match a status, an assignee or free text, newest first, at most 20 per call. Use this to find ticket ids; it does not change anything.", "inputSchema": {"type": "object", "properties": {"status": {"type": "string", "enum": ["open", "resolved", "closed"], "description": "Only tickets in this status."}, "assignee": {"type": "string", "description": "User name or id of the assignee."}, "text": {"type": "string", "description": "Words that must appear in the title or a comment."}}, "required": []}, "annotations": {"readOnlyHint": true}},
  {"name": "tickets_create", "description": "Create a new support ticket and return its id. Use it only for a problem that has no ticket yet; search first to avoid duplicates.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string", "description": "One-line summary of the problem."}, "body": {"type": "string", "description": "Steps to reproduce and expected behaviour."}}, "required": ["title"]}, "annotations": {"readOnlyHint": false, "destructiveHint": false}},
  {"name": "tickets_update_status", "description": "Move one ticket to a new status. Use it to close, resolve or reopen a ticket; it does not add comments.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}, "status": {"type": "string", "enum": ["open", "resolved", "closed"], "description": "The new status."}}, "required": ["ticket_id", "status"]}, "annotations": {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true}},
  {"name": "tickets_add_comment", "description": "Append a comment to one ticket. Use it for notes and replies; comments cannot be edited afterwards.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}, "comment": {"type": "string", "description": "Comment text, plain text only."}}, "required": ["ticket_id", "comment"]}, "annotations": {"readOnlyHint": false, "destructiveHint": false}},
  {"name": "tickets_delete", "description": "Permanently delete one ticket and its comments. This cannot be undone; use tickets_update_status to close a ticket instead.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}}, "required": ["ticket_id"]}, "annotations": {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true}},
  {"name": "users_find", "description": "Look up users by name or email and return their ids. Use it to turn a person's name into the assignee value of tickets_search.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string", "description": "Full or partial name, or an email address."}}, "required": ["name"]}, "annotations": {"readOnlyHint": true}},
  {"name": "reports_weekly_summary", "description": "Write the weekly summary of closed tickets for one team as markdown. Reads tickets only.", "inputSchema": {"type": "object", "properties": {"team": {"type": "string", "description": "Team name, for example support."}}, "required": ["team"]}, "annotations": {"readOnlyHint": true}}
]}}
clear-tools.json
{"jsonrpc": "2.0", "id": 1, "result": {"resultType": "complete", "ttlMs": 0, "cacheScope": "public", "tools": [
  {"name": "tickets_get", "description": "Show one support ticket (title, status, assignee, comments) when you already know its numeric id. Use tickets_search when the id is not known.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}}, "required": ["ticket_id"]}, "annotations": {"readOnlyHint": true}},
  {"name": "tickets_search", "description": "Find support tickets by status (open, resolved, closed), by who they are assigned to, or by free text, newest first, at most 20 per call. Use this to find ticket ids; it does not change anything.", "inputSchema": {"type": "object", "properties": {"status": {"type": "string", "enum": ["open", "resolved", "closed"], "description": "Only tickets in this status."}, "assignee": {"type": "string", "description": "User name or id of the assignee."}, "text": {"type": "string", "description": "Words that must appear in the title or a comment."}}, "required": []}, "annotations": {"readOnlyHint": true}},
  {"name": "tickets_create", "description": "Create a new support ticket and return its id. Use it only for a problem that has no ticket yet; run tickets_search first so the problem is not filed twice.", "inputSchema": {"type": "object", "properties": {"title": {"type": "string", "description": "One-line summary of the problem."}, "body": {"type": "string", "description": "Steps to reproduce and expected behaviour."}}, "required": ["title"]}, "annotations": {"readOnlyHint": false, "destructiveHint": false}},
  {"name": "tickets_update_status", "description": "Move one ticket to a new status: close it, mark it resolved, or reopen it. It does not add comments.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}, "status": {"type": "string", "enum": ["open", "resolved", "closed"], "description": "The new status."}}, "required": ["ticket_id", "status"]}, "annotations": {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true}},
  {"name": "tickets_add_comment", "description": "Append a comment to one ticket. Use it for notes and replies; comments cannot be edited afterwards.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}, "comment": {"type": "string", "description": "Comment text, plain text only."}}, "required": ["ticket_id", "comment"]}, "annotations": {"readOnlyHint": false, "destructiveHint": false}},
  {"name": "tickets_delete", "description": "Permanently remove one ticket and its comments. This cannot be undone; to keep the history, change the status instead.", "inputSchema": {"type": "object", "properties": {"ticket_id": {"type": "integer", "description": "Numeric ticket id, for example 4521."}}, "required": ["ticket_id"]}, "annotations": {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": true}},
  {"name": "users_find", "description": "Look up users by name or email and return their ids. Use it to turn a person's name into the assignee value of tickets_search.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string", "description": "Full or partial name, or an email address."}}, "required": ["name"]}, "annotations": {"readOnlyHint": true}},
  {"name": "reports_weekly_summary", "description": "Write the weekly summary of closed tickets for one team as markdown. Reads tickets only.", "inputSchema": {"type": "object", "properties": {"team": {"type": "string", "description": "Team name, for example support."}}, "required": ["team"]}, "annotations": {"readOnlyHint": true}}
]}}
tasks-confusing.json
[
  {"task": "Show me ticket 4521", "expect": "get_data"},
  {"task": "Find open tickets assigned to Maria", "expect": "search"},
  {"task": "Close ticket 4521 as resolved", "expect": "update"},
  {"task": "Remove the duplicate ticket 4533", "expect": "delete"},
  {"task": "Write the weekly summary of closed tickets for the support team", "expect": "run"},
  {"task": "Look up the user named Maria Chen", "expect": "find"},
  {"task": "Add a comment to ticket 4521 saying the fix is deployed", "expect": "update"},
  {"task": "Create a new ticket for the login bug", "expect": "create"}
]
tasks-clear.json
[
  {"task": "Show me ticket 4521", "expect": "tickets_get"},
  {"task": "Find open tickets assigned to Maria", "expect": "tickets_search"},
  {"task": "Close ticket 4521 as resolved", "expect": "tickets_update_status"},
  {"task": "Remove the duplicate ticket 4533", "expect": "tickets_delete"},
  {"task": "Write the weekly summary of closed tickets for the support team", "expect": "reports_weekly_summary"},
  {"task": "Look up the user named Maria Chen", "expect": "users_find"},
  {"task": "Add a comment to ticket 4521 saying the fix is deployed", "expect": "tickets_add_comment"},
  {"task": "Create a new ticket for the login bug", "expect": "tickets_create"}
]

四、检查脚本看什么

tool_design_check.py 读取保存下来的 tools/list 响应(纯 JSON 或一行 SSE data)、它的 result,或一个工具数组。FAIL 表示违反了规范对名称或 inputSchema 的规则,或某个选择任务答错;WARN 表示脚本的某条经验规则没满足。没有 FAIL 时退出码为 0,至少一个 FAIL 为 1,输入读不出来为 2。

检查级别它指出什么依据
list-completeWARN有 nextCursor,只读到了第一页tools/list 的分页
name-specFAIL名称不在 1 到 128 个 A-Z a-z 0-9 _ - . 字符之内规范“Tool Names”
name-uniqueFAIL同一个名称出现两次规范“Tool Names”
name-case-collisionWARN两个名称只在大小写或分隔符上不同(TicketsGet、tickets_get)经验规则
name-styleWARN同时出现 snake_case、kebab-case、点分和 camelCase 中的多种经验规则
name-genericWARN名称只由 get、data、run、search 这类通用词组成经验规则
input-schemaFAILinputSchema 不是对象规范“Tool”
descriptionWARN没有描述,或不足 8 个词经验规则
params-describedWARN参数既没有描述,也没有 enum 或 const经验规则
params-requiredWARN声明了参数但没有 required经验规则
params-opaqueWARN对象参数没有 properties经验规则
no-params-closedWARN无参数的工具缺少 additionalProperties: false规范“Tool”(推荐)
annotationsWARN名称里的第一个动词(取自脚本的读、写动词表)决定:读动词缺少 readOnlyHint,或写动词既没有 readOnlyHint 也没有 destructiveHint基于 schema 默认值的经验规则
near-duplicateWARN两条描述共享一半或更多的词经验规则
select-NFAIL 或 WARN第 N 个任务:期望的工具不是排名第一,或任务中没有任何词出现在任何定义里,或第二名的得分达到第一名的 80% 以内第五节

SHORT = 8、DUPLICATE = 0.5、AMBIGUOUS = 0.8 是脚本自己的设定,没有官方出处;你的定义风格不同时可以改。动词表很短,且只看名称,所以算术工具里的 add 会被当作写动词报告(第六节可以看到)。

tool_design_check.py
#!/usr/bin/env python3
"""Check how well MCP tool definitions help a model choose and call the right tool, and run a selection test.

Usage: tool_design_check.py SOURCE [--tasks FILE]
SOURCE: JSON file ("-" = stdin) holding a tools/list response (plain JSON or one SSE data line), its result,
or a tools array.
--tasks: JSON array of {"task": "...", "expect": "tool_name"}. Each task is ranked against the tools by weighted
word overlap, a stand-in for a model that only has the words of your definitions to go on.
Levels: FAIL = a rule of the spec on names or inputSchema is broken, or a selection task is answered wrongly;
WARN = a design heuristic of this script is not met (heuristics are not spec text).
Exit code: 0 no FAIL, 1 at least one FAIL, 2 the input cannot be read.
"""
import argparse
import json
import math
import re
import sys

NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
RUN_RE = re.compile(r"[A-Za-z0-9]+")
PART_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+")
STOP = set("a an the of to in on for and or is are be it this that with by from as at when you your use used "
           "using into any all not no if then than can will which what who whom i me my we our".split())
GENERIC = set("get fetch query run do execute data info information tool process handle search find list update "
              "delete create read write call action item thing stuff object result record manage".split())
WRITE_VERBS = set("create update delete remove drop send write set add post put patch cancel close publish deploy "
                  "insert upload reset clear edit rename move run execute".split())
READ_VERBS = set("get list search find read fetch query lookup show view describe check".split())
AMBIGUOUS = 0.8       # the runner-up scores at least this share of the winner: the definitions do not separate
DUPLICATE = 0.5       # Jaccard overlap of two descriptions that is reported as a near-duplicate
SHORT = 8             # a description with fewer words cannot say both what the tool does and when to use it
counts = {"PASS": 0, "WARN": 0, "FAIL": 0}


def report(name, ok, detail, level="WARN"):
    status = "PASS" if ok else level
    counts[status] += 1
    print("%s %s: %s" % (status, name, detail))


def tokens(text):
    """Lower-case word parts of `text`, split at camelCase, digits and separators, plural 's' folded."""
    out = []
    for run in RUN_RE.findall(text or ""):
        for part in PART_RE.findall(run):
            t = part.lower()
            if t not in STOP and len(t) > 3 and t.endswith("s") and not t.endswith("ss"):
                t = t[:-1]
            out.append(t)
    return out


def content(text):
    return {t for t in tokens(text) if t not in STOP}


def style(name):
    for mark, label in (("_", "snake_case"), ("-", "kebab-case"), (".", "dotted")):
        if mark in name:
            return label
    return "camelCase" if re.search(r"[a-z][A-Z]", name) else None


def load(source):
    """Return the tools array of a tools/list response, its result, or a bare array; and the result envelope."""
    text = sys.stdin.read() if source == "-" else open(source, encoding="utf-8").read()
    try:
        data = json.loads(text)
    except ValueError:
        data = None
        for line in reversed(text.splitlines()):
            if line.startswith("data:"):
                data = json.loads(line[5:].strip())
                break
        if data is None:
            raise ValueError("%s holds neither JSON nor an SSE data line" % source)
    if isinstance(data, dict) and "error" in data:
        raise ValueError("the response is a JSON-RPC error: %s" % json.dumps(data["error"])[:160])
    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 {}


def lint(tools, envelope):
    names = [t["name"] for t in tools]
    size = len(json.dumps(tools, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
    print("INFO %d tools, %d bytes as compact JSON (a client sends this, in some form, to the model)"
          % (len(tools), size))
    report("list-complete", not envelope.get("nextCursor"), "the listing has no further page"
           if not envelope.get("nextCursor") else "nextCursor present: only the first page was read")
    bad = [n for n in names if not NAME_RE.match(n)]
    report("name-spec", not bad, "all names are 1-128 characters from A-Z a-z 0-9 _ - ." if not bad
           else "outside the allowed set: %s" % ", ".join(repr(n) for n in bad), "FAIL")
    dup = sorted({n for n in names if names.count(n) > 1})
    report("name-unique", not dup, "every name is unique" if not dup else "repeated: %s" % ", ".join(dup), "FAIL")
    folded = {}
    for n in names:
        folded.setdefault("".join(tokens(n)), []).append(n)
    clash = [v for v in folded.values() if len(set(v)) > 1]
    report("name-case-collision", not clash, "no two names differ only in case or separators" if not clash
           else "differ only in case or separators: %s" % "; ".join(" / ".join(sorted(set(v))) for v in clash))
    styles = sorted({s for s in map(style, names) if s})
    report("name-style", len(styles) < 2, "one naming style%s" % (" (%s)" % styles[0] if styles else "")
           if len(styles) < 2 else "mixed styles: %s" % ", ".join(styles))
    generic = [n for n in names if tokens(n) and set(tokens(n)) <= GENERIC]
    report("name-generic", not generic, "every name says which object it works on" if not generic
           else "names that never say which object: %s" % ", ".join(generic))
    for t in tools:
        n = t["name"]
        desc = t.get("description")
        schema = t.get("inputSchema")
        ok = isinstance(schema, dict)
        report("input-schema:%s" % n, ok, "inputSchema is a JSON Schema object" if ok
               else "inputSchema must be an object, got %s" % type(schema).__name__, "FAIL")
        schema = schema if ok else {}
        words = tokens(desc) if isinstance(desc, str) else []
        report("description:%s" % n, len(words) >= SHORT, "%d words" % len(words) if len(words) >= SHORT
               else ("no description" if not words else "%d words, fewer than %d" % (len(words), SHORT)))
        props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
        loose = [k for k, v in props.items() if not isinstance(v, dict)
                 or not (v.get("description") or "enum" in v or "const" in v)]
        report("params-described:%s" % n, not loose, "every parameter has a description, enum or const"
               if not loose else "no description or enum: %s" % ", ".join(loose))
        if props:
            report("params-required:%s" % n, "required" in schema, "required is declared"
                   if "required" in schema else "no `required` (use an empty list when all are optional)")
        opaque = [k for k, v in props.items() if isinstance(v, dict) and v.get("type") == "object"
                  and not v.get("properties")]
        report("params-opaque:%s" % n, not opaque, "no free-form object parameter" if not opaque
               else "object parameter without `properties`: %s" % ", ".join(opaque))
        if not props:
            closed = schema.get("additionalProperties") is False
            report("no-params-closed:%s" % n, closed, "closed with additionalProperties: false" if closed
                   else "a tool without parameters should say additionalProperties: false")
        verb = next((w for w in tokens(n) if w in WRITE_VERBS or w in READ_VERBS), None)
        hints = t.get("annotations") if isinstance(t.get("annotations"), dict) else {}
        if verb:
            want = ("readOnlyHint", "destructiveHint") if verb in WRITE_VERBS else ("readOnlyHint",)
            done = any(k in hints for k in want)
            report("annotations:%s" % n, done, "%s is set" % " or ".join(want) if done
                   else "`%s` is not set; the schema default says the tool may modify and be destructive"
                   % "` or `".join(want))
    docs = {t["name"]: content(t.get("description") or "") for t in tools}
    pairs = []
    for i, a in enumerate(names):
        for b in names[i + 1:]:
            union = docs[a] | docs[b]
            if a != b and union and len(docs[a] & docs[b]) / len(union) >= DUPLICATE:
                pairs.append("%s and %s (%.0f%%)" % (a, b, 100 * len(docs[a] & docs[b]) / len(union)))
    report("near-duplicate", not pairs, "no two descriptions overlap by %.0f%% or more" % (100 * DUPLICATE)
           if not pairs else "descriptions that share most of their words: %s" % "; ".join(pairs))


def select(tools, tasks):
    """Rank every tool for every task; a tool wins by weighted word overlap, name words count three times."""
    docs = []
    for t in tools:
        body = content(t.get("title")) | content(t.get("description"))
        props = (t.get("inputSchema") or {}).get("properties")
        for k, v in (props.items() if isinstance(props, dict) else []):
            v = v if isinstance(v, dict) else {}
            body |= content(k) | content(v.get("description"))
            for choice in v.get("enum") or []:
                body |= content(choice) if isinstance(choice, str) else set()
        docs.append((content(t["name"]), body))
    df = {}
    for name_t, body_t in docs:
        for w in name_t | body_t:
            df[w] = df.get(w, 0) + 1
    good = ambiguous = wrong = 0
    for i, item in enumerate(tasks, 1):
        want = item["expect"]
        scores = []
        for t, (name_t, body_t) in zip(tools, docs):
            s = 0.0
            for w in content(item["task"]):
                if w in df:
                    weight = math.log(1 + len(tools) / df[w])
                    s += 3 * weight if w in name_t else weight if w in body_t else 0
            scores.append((s, t["name"]))
        scores.sort(key=lambda p: (-p[0], p[1]))
        top = scores[0][0]
        mine = next((s for s, n in scores if n == want), None)
        label = "select-%d" % i
        shown = item["task"] if len(item["task"]) <= 50 else item["task"][:47] + "..."
        if mine is None:
            report(label, False, "expected tool %r is not in the listing" % want, "FAIL")
            wrong += 1
        elif top == 0:
            report(label, False, '"%s": no word of the task appears in any definition' % shown, "FAIL")
            wrong += 1
        elif mine < top:
            report(label, False, '"%s": %s wins (%.1f), %s scores %.1f' % (shown, scores[0][1], top, want, mine),
                   "FAIL")
            wrong += 1
        else:
            runner = next((p for p in scores if p[1] != want), (0.0, "-"))
            if runner[0] >= AMBIGUOUS * top:
                report(label, False, '"%s": %s (%.1f) is not clearly ahead of %s (%.1f)'
                       % (shown, want, top, runner[1], runner[0]))
                ambiguous += 1
            else:
                report(label, True, '"%s": %s (%.1f), next %s %.1f' % (shown, want, top, runner[1], runner[0]))
                good += 1
    print("INFO selection: %d tasks, %d clear, %d ambiguous, %d wrong or not found"
          % (len(tasks), good, ambiguous, wrong))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("source")
    ap.add_argument("--tasks", help="JSON file with [{\"task\": ..., \"expect\": tool name}]")
    args = ap.parse_args()
    try:
        tools, envelope = load(args.source)
        bad = [t for t in tools if not isinstance(t, dict) or not isinstance(t.get("name"), str)]
        if bad:
            raise ValueError("%d entries of the tools array have no string `name`" % len(bad))
        tasks = json.load(open(args.tasks, encoding="utf-8")) if args.tasks else []
        if not all(isinstance(i, dict) and isinstance(i.get("task"), str) and isinstance(i.get("expect"), str)
                   for i in tasks):
            raise ValueError("every task needs string fields `task` and `expect`")
    except (OSError, ValueError) as exc:
        print("ERROR %s" % exc)
        return 2
    lint(tools, envelope)
    if tasks:
        select(tools, tasks)
    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())

五、选择测试,以及它能证明什么

选择测试是一个词重叠排序器,不是语言模型。它能发现与任务共享的词太少、或彼此共享的词太多的定义,却无法说明你的模型会怎么表现。

脚本对每个任务给每个工具排名。词先转小写,在驼峰、数字和分隔符处切开,去掉常见虚词,复数的 s 折叠掉。出现在名称里的词算三次,出现在标题、描述、参数名、参数描述或 enum 取值里的词算一次,每个词再乘以权重 ln(1 + N / df),其中 N 是工具数,df 是含这个词的工具数,所以被许多工具共有的词分量更轻。当期望的工具排第一、且第二名的得分低于它的 80% 时,任务算清晰;差距更小(包括平分)算含糊;别的工具排第一,或任务里没有任何词出现在任何定义里,算错误。

一次运行能显示:

  • 词汇缺口:你的用户用的词没有出现在任何定义里。模型只能靠自己的知识去弥合。
  • 重叠:两个工具在排序器看来很像,模型几乎没有可依据的差别。
  • 词汇磁铁:邻居工具的描述里有任务的词,而正确工具的描述里没有。

它不能显示的是:模型会不会选对。模型读的是语义而不是词,可能弥合排序器弥合不了的缺口。FAIL 的意思是“这份定义把选择留给了模型”,而不是“模型会失败”。

六、运行结果

命令在放着脚本和样例的目录里运行。

python3 tool_design_check.py confusing-tools.json --tasks tasks-confusing.json
python3 tool_design_check.py draft-tools.json --tasks tasks-clear.json
python3 tool_design_check.py clear-tools.json --tasks tasks-clear.json
版本结果行选择退出码
confusing-tools.json22 pass, 45 warn, 2 fail2 清晰,4 含糊,2 错误1
draft-tools.json59 pass, 1 warn, 2 fail5 清晰,1 含糊,2 错误1
clear-tools.json62 pass, 0 warn, 0 fail8 清晰0
原始输出:confusing-tools.json
INFO 9 tools, 1170 bytes as compact JSON (a client sends this, in some form, to the model)
PASS list-complete: the listing has no further page
PASS name-spec: all names are 1-128 characters from A-Z a-z 0-9 _ - .
PASS name-unique: every name is unique
PASS name-case-collision: no two names differ only in case or separators
PASS name-style: one naming style (snake_case)
WARN name-generic: names that never say which object: get_data, fetch_data, query, search, find, run, create, update, delete
PASS input-schema:get_data: inputSchema is a JSON Schema object
WARN description:get_data: 5 words, fewer than 8
WARN params-described:get_data: no description or enum: id
WARN params-required:get_data: no `required` (use an empty list when all are optional)
PASS params-opaque:get_data: no free-form object parameter
WARN annotations:get_data: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:fetch_data: inputSchema is a JSON Schema object
WARN description:fetch_data: 3 words, fewer than 8
WARN params-described:fetch_data: no description or enum: id
WARN params-required:fetch_data: no `required` (use an empty list when all are optional)
PASS params-opaque:fetch_data: no free-form object parameter
WARN annotations:fetch_data: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:query: inputSchema is a JSON Schema object
WARN description:query: 6 words, fewer than 8
WARN params-described:query: no description or enum: filter
WARN params-required:query: no `required` (use an empty list when all are optional)
WARN params-opaque:query: object parameter without `properties`: filter
WARN annotations:query: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:search: inputSchema is a JSON Schema object
WARN description:search: 3 words, fewer than 8
WARN params-described:search: no description or enum: q
WARN params-required:search: no `required` (use an empty list when all are optional)
PASS params-opaque:search: no free-form object parameter
WARN annotations:search: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:find: inputSchema is a JSON Schema object
WARN description:find: 5 words, fewer than 8
WARN params-described:find: no description or enum: text
WARN params-required:find: no `required` (use an empty list when all are optional)
PASS params-opaque:find: no free-form object parameter
WARN annotations:find: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:run: inputSchema is a JSON Schema object
WARN description:run: 4 words, fewer than 8
WARN params-described:run: no description or enum: job
WARN params-required:run: no `required` (use an empty list when all are optional)
PASS params-opaque:run: no free-form object parameter
WARN annotations:run: `readOnlyHint` or `destructiveHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:create: inputSchema is a JSON Schema object
WARN description:create: 4 words, fewer than 8
WARN params-described:create: no description or enum: data
WARN params-required:create: no `required` (use an empty list when all are optional)
WARN params-opaque:create: object parameter without `properties`: data
WARN annotations:create: `readOnlyHint` or `destructiveHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:update: inputSchema is a JSON Schema object
WARN description:update: 3 words, fewer than 8
WARN params-described:update: no description or enum: id, data
WARN params-required:update: no `required` (use an empty list when all are optional)
WARN params-opaque:update: object parameter without `properties`: data
WARN annotations:update: `readOnlyHint` or `destructiveHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:delete: inputSchema is a JSON Schema object
WARN description:delete: 3 words, fewer than 8
WARN params-described:delete: no description or enum: id
WARN params-required:delete: no `required` (use an empty list when all are optional)
PASS params-opaque:delete: no free-form object parameter
WARN annotations:delete: `readOnlyHint` or `destructiveHint` is not set; the schema default says the tool may modify and be destructive
WARN near-duplicate: descriptions that share most of their words: fetch_data and search (50%); fetch_data and update (50%); fetch_data and delete (50%); search and update (50%); search and delete (50%); update and delete (50%)
WARN select-1: "Show me ticket 4521": get_data (0.8) is not clearly ahead of create (0.8)
FAIL select-2: "Find open tickets assigned to Maria": find wins (7.7), search scores 0.8
WARN select-3: "Close ticket 4521 as resolved": update (0.8) is not clearly ahead of create (0.8)
WARN select-4: "Remove the duplicate ticket 4533": delete (0.8) is not clearly ahead of create (0.8)
FAIL select-5: "Write the weekly summary of closed tickets for ...": create wins (0.8), run scores 0.0
PASS select-6: "Look up the user named Maria Chen": find (2.3), next create 0.0
WARN select-7: "Add a comment to ticket 4521 saying the fix is ...": update (0.8) is not clearly ahead of create (0.8)
PASS select-8: "Create a new ticket for the login bug": create (10.0), next delete 0.8
INFO selection: 8 tasks, 2 clear, 4 ambiguous, 2 wrong or not found
22 pass, 45 warn, 2 fail
原始输出:draft-tools.json
INFO 8 tools, 3431 bytes as compact JSON (a client sends this, in some form, to the model)
PASS list-complete: the listing has no further page
PASS name-spec: all names are 1-128 characters from A-Z a-z 0-9 _ - .
PASS name-unique: every name is unique
PASS name-case-collision: no two names differ only in case or separators
PASS name-style: one naming style (snake_case)
PASS name-generic: every name says which object it works on
PASS input-schema:tickets_get: inputSchema is a JSON Schema object
PASS description:tickets_get: 24 words
PASS params-described:tickets_get: every parameter has a description, enum or const
PASS params-required:tickets_get: required is declared
PASS params-opaque:tickets_get: no free-form object parameter
PASS annotations:tickets_get: readOnlyHint is set
PASS input-schema:tickets_search: inputSchema is a JSON Schema object
PASS description:tickets_search: 30 words
PASS params-described:tickets_search: every parameter has a description, enum or const
PASS params-required:tickets_search: required is declared
PASS params-opaque:tickets_search: no free-form object parameter
PASS annotations:tickets_search: readOnlyHint is set
PASS input-schema:tickets_create: inputSchema is a JSON Schema object
PASS description:tickets_create: 25 words
PASS params-described:tickets_create: every parameter has a description, enum or const
PASS params-required:tickets_create: required is declared
PASS params-opaque:tickets_create: no free-form object parameter
PASS annotations:tickets_create: readOnlyHint or destructiveHint is set
PASS input-schema:tickets_update_status: inputSchema is a JSON Schema object
PASS description:tickets_update_status: 21 words
PASS params-described:tickets_update_status: every parameter has a description, enum or const
PASS params-required:tickets_update_status: required is declared
PASS params-opaque:tickets_update_status: no free-form object parameter
PASS annotations:tickets_update_status: readOnlyHint or destructiveHint is set
PASS input-schema:tickets_add_comment: inputSchema is a JSON Schema object
PASS description:tickets_add_comment: 17 words
PASS params-described:tickets_add_comment: every parameter has a description, enum or const
PASS params-required:tickets_add_comment: required is declared
PASS params-opaque:tickets_add_comment: no free-form object parameter
PASS annotations:tickets_add_comment: readOnlyHint or destructiveHint is set
PASS input-schema:tickets_delete: inputSchema is a JSON Schema object
PASS description:tickets_delete: 20 words
PASS params-described:tickets_delete: every parameter has a description, enum or const
PASS params-required:tickets_delete: required is declared
PASS params-opaque:tickets_delete: no free-form object parameter
PASS annotations:tickets_delete: readOnlyHint or destructiveHint is set
PASS input-schema:users_find: inputSchema is a JSON Schema object
PASS description:users_find: 26 words
PASS params-described:users_find: every parameter has a description, enum or const
PASS params-required:users_find: required is declared
PASS params-opaque:users_find: no free-form object parameter
PASS annotations:users_find: readOnlyHint is set
PASS input-schema:reports_weekly_summary: inputSchema is a JSON Schema object
PASS description:reports_weekly_summary: 15 words
PASS params-described:reports_weekly_summary: every parameter has a description, enum or const
PASS params-required:reports_weekly_summary: required is declared
PASS params-opaque:reports_weekly_summary: no free-form object parameter
PASS near-duplicate: no two descriptions overlap by 50% or more
WARN select-1: "Show me ticket 4521": tickets_get (3.2) is not clearly ahead of tickets_add_comment (3.2)
FAIL select-2: "Find open tickets assigned to Maria": users_find wins (5.5), tickets_search scores 5.3
PASS select-3: "Close ticket 4521 as resolved": tickets_update_status (6.4), next tickets_delete 4.8
FAIL select-4: "Remove the duplicate ticket 4533": tickets_create wins (4.3), tickets_delete scores 2.1
PASS select-5: "Write the weekly summary of closed tickets for ...": reports_weekly_summary (18.9), next tickets_create 4.8
PASS select-6: "Look up the user named Maria Chen": users_find (9.2), next tickets_search 1.6
PASS select-7: "Add a comment to ticket 4521 saying the fix is ...": tickets_add_comment (10.9), next tickets_update_status 5.7
PASS select-8: "Create a new ticket for the login bug": tickets_create (10.3), next tickets_update_status 3.7
INFO selection: 8 tasks, 5 clear, 1 ambiguous, 2 wrong or not found
59 pass, 1 warn, 2 fail
原始输出:clear-tools.json
INFO 8 tools, 3482 bytes as compact JSON (a client sends this, in some form, to the model)
PASS list-complete: the listing has no further page
PASS name-spec: all names are 1-128 characters from A-Z a-z 0-9 _ - .
PASS name-unique: every name is unique
PASS name-case-collision: no two names differ only in case or separators
PASS name-style: one naming style (snake_case)
PASS name-generic: every name says which object it works on
PASS input-schema:tickets_get: inputSchema is a JSON Schema object
PASS description:tickets_get: 24 words
PASS params-described:tickets_get: every parameter has a description, enum or const
PASS params-required:tickets_get: required is declared
PASS params-opaque:tickets_get: no free-form object parameter
PASS annotations:tickets_get: readOnlyHint is set
PASS input-schema:tickets_search: inputSchema is a JSON Schema object
PASS description:tickets_search: 36 words
PASS params-described:tickets_search: every parameter has a description, enum or const
PASS params-required:tickets_search: required is declared
PASS params-opaque:tickets_search: no free-form object parameter
PASS annotations:tickets_search: readOnlyHint is set
PASS input-schema:tickets_create: inputSchema is a JSON Schema object
PASS description:tickets_create: 31 words
PASS params-described:tickets_create: every parameter has a description, enum or const
PASS params-required:tickets_create: required is declared
PASS params-opaque:tickets_create: no free-form object parameter
PASS annotations:tickets_create: readOnlyHint or destructiveHint is set
PASS input-schema:tickets_update_status: inputSchema is a JSON Schema object
PASS description:tickets_update_status: 20 words
PASS params-described:tickets_update_status: every parameter has a description, enum or const
PASS params-required:tickets_update_status: required is declared
PASS params-opaque:tickets_update_status: no free-form object parameter
PASS annotations:tickets_update_status: readOnlyHint or destructiveHint is set
PASS input-schema:tickets_add_comment: inputSchema is a JSON Schema object
PASS description:tickets_add_comment: 17 words
PASS params-described:tickets_add_comment: every parameter has a description, enum or const
PASS params-required:tickets_add_comment: required is declared
PASS params-opaque:tickets_add_comment: no free-form object parameter
PASS annotations:tickets_add_comment: readOnlyHint or destructiveHint is set
PASS input-schema:tickets_delete: inputSchema is a JSON Schema object
PASS description:tickets_delete: 19 words
PASS params-described:tickets_delete: every parameter has a description, enum or const
PASS params-required:tickets_delete: required is declared
PASS params-opaque:tickets_delete: no free-form object parameter
PASS annotations:tickets_delete: readOnlyHint or destructiveHint is set
PASS input-schema:users_find: inputSchema is a JSON Schema object
PASS description:users_find: 26 words
PASS params-described:users_find: every parameter has a description, enum or const
PASS params-required:users_find: required is declared
PASS params-opaque:users_find: no free-form object parameter
PASS annotations:users_find: readOnlyHint is set
PASS input-schema:reports_weekly_summary: inputSchema is a JSON Schema object
PASS description:reports_weekly_summary: 15 words
PASS params-described:reports_weekly_summary: every parameter has a description, enum or const
PASS params-required:reports_weekly_summary: required is declared
PASS params-opaque:reports_weekly_summary: no free-form object parameter
PASS near-duplicate: no two descriptions overlap by 50% or more
PASS select-1: "Show me ticket 4521": tickets_get (5.4), next tickets_add_comment 3.2
PASS select-2: "Find open tickets assigned to Maria": tickets_search (7.5), next users_find 5.5
PASS select-3: "Close ticket 4521 as resolved": tickets_update_status (7.0), next tickets_search 3.7
PASS select-4: "Remove the duplicate ticket 4533": tickets_delete (4.3), next tickets_add_comment 2.1
PASS select-5: "Write the weekly summary of closed tickets for ...": reports_weekly_summary (18.9), next tickets_create 4.8
PASS select-6: "Look up the user named Maria Chen": users_find (9.2), next tickets_search 1.6
PASS select-7: "Add a comment to ticket 4521 saying the fix is ...": tickets_add_comment (10.9), next tickets_update_status 5.7
PASS select-8: "Create a new ticket for the login bug": tickets_create (10.3), next tickets_update_status 3.7
INFO selection: 8 tasks, 8 clear, 0 ambiguous, 0 wrong or not found
62 pass, 0 warn, 0 fail

草稿教会了什么。 两个任务失败。“Find open tickets assigned to Maria” 被送给了 users_find:它的名称里有 find,而名称里的词算三次,tickets_search 却用“match a status, an assignee or free text”描述自己。“Remove the duplicate ticket 4533” 被送给了 tickets_create,它的描述里写着“avoid duplicates”,而 tickets_delete 从没用过“remove”这个词。还有一个任务打成平手:“Show me ticket 4521” 与任何描述都只共享 ticket 和示例编号 4521,所以每个带 ticket_id 参数的 tickets_* 工具得分相同。修正只是改措辞:tickets_search 现在写“Find … assigned to”,tickets_get 写“Show”,tickets_delete 写“remove”,tickets_create 说问题不该被“filed twice”,tickets_update_status 写“close it, mark it resolved”。改成用户的词,再重跑。这个循环是在按词排序器优化定义,所以最后一定要做第七节。

官方 SDK 的实时列表

MCP 工具详解里的参考服务器(五个工具,官方 Python SDK 2.3.0)的列表用 curl 取下并检查。请求带着 2026-07-28 修订版要求的每请求 _meta。

curl -s http://127.0.0.1:8200/mcp -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"design-check","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}' \
  > sdk-tools.json
python3 tool_design_check.py sdk-tools.json

结果是 22 pass, 14 warn, 0 fail,退出码 0:没有违反规范的规则。警告是 SDK 按文档字符串生成的定义所缺的东西:两到六个词的描述、没有描述的参数(location、a、b)、没有注解,以及三个无参数的工具没有 additionalProperties: false。add 被当成写动词,因为它以 add 开头;它只是求两个数的和,正确的修正是 readOnlyHint: true。这就是按名称判断的经验规则会出现的误报。

原始输出:官方 SDK 的实时列表
INFO 5 tools, 1838 bytes as compact JSON (a client sends this, in some form, to the model)
PASS list-complete: the listing has no further page
PASS name-spec: all names are 1-128 characters from A-Z a-z 0-9 _ - .
PASS name-unique: every name is unique
PASS name-case-collision: no two names differ only in case or separators
PASS name-style: one naming style (snake_case)
PASS name-generic: every name says which object it works on
PASS input-schema:get_weather: inputSchema is a JSON Schema object
WARN description:get_weather: 6 words, fewer than 8
WARN params-described:get_weather: no description or enum: location
PASS params-required:get_weather: required is declared
PASS params-opaque:get_weather: no free-form object parameter
WARN annotations:get_weather: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:list_users: inputSchema is a JSON Schema object
WARN description:list_users: 6 words, fewer than 8
PASS params-described:list_users: every parameter has a description, enum or const
PASS params-opaque:list_users: no free-form object parameter
WARN no-params-closed:list_users: a tool without parameters should say additionalProperties: false
WARN annotations:list_users: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:add: inputSchema is a JSON Schema object
WARN description:add: 3 words, fewer than 8
WARN params-described:add: no description or enum: a, b
PASS params-required:add: required is declared
PASS params-opaque:add: no free-form object parameter
WARN annotations:add: `readOnlyHint` or `destructiveHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:get_current_time: inputSchema is a JSON Schema object
WARN description:get_current_time: 3 words, fewer than 8
PASS params-described:get_current_time: every parameter has a description, enum or const
PASS params-opaque:get_current_time: no free-form object parameter
WARN no-params-closed:get_current_time: a tool without parameters should say additionalProperties: false
WARN annotations:get_current_time: `readOnlyHint` is not set; the schema default says the tool may modify and be destructive
PASS input-schema:explode: inputSchema is a JSON Schema object
WARN description:explode: 2 words, fewer than 8
PASS params-described:explode: every parameter has a description, enum or const
PASS params-opaque:explode: no free-form object parameter
WARN no-params-closed:explode: a tool without parameters should say additionalProperties: false
PASS near-duplicate: no two descriptions overlap by 50% or more
22 pass, 14 warn, 0 fail

七、用你自己的模型测

排序器只是替身。真正要紧的测试,输入是同一份任务文件。

  1. 保存你的服务器对真实用户凭据返回的 tools/list 结果。
  2. 用用户的话写任务,每个工具至少一个,再加几个任何工具都不该处理的任务。检查脚本的文件里没有“无工具”这种期望,这种情形请在你自己的测试程序里记录。
  3. 把每个任务连同列表交给你的模型,记录它调用的第一个工具和参数。不要算后面的调用。
  4. 与期望的工具对照,对照第八节的失效模式表读每一个失误,改定义,然后重跑本检查脚本和模型测试。
  5. 把任务文件放进版本控制;列表一有改动就重跑,因为列表就是提示词的一部分。

通过线由你定。这里不给通过率,因为没有一手来源给出过。

八、失效模式与恢复

现象可能原因怎么发现恢复
模型为 A 的活调用了工具 B描述重叠,或 B 的文字里有任务的词near-duplicate;含糊或错误的选择任务写明各自何时用、何时改用另一个;去掉借来的词
某个工具从来没被选中用户的词没有出现在任何定义里选择任务 FAIL “no word of the task appears”在名称和描述里用用户的词;在参数描述里放示例值
参数格式不对参数既没有描述也没有 enumparams-described用示例描述格式;取值有限就用 enum
模型反复发同一个无效调用错误文字没有指出规则读你自己的 isError 结果像规范示例那样,写明错在哪、什么才合法
本想读却发生了写一个工具既读又写,或没有注解annotations按效果拆开;设置 readOnlyHint 与 destructiveHint;保留客户端的确认
不同连接看到的工具不同列表因连接而异,规范禁止;只有授权可以改变它用两套凭据各列一次并对比保持列表稳定,只随授权变化
合并的两个服务器都有 search通用名称在聚合器里冲突name-generic把对象写进名称;客户端仍应加服务器标识作前缀
模型在过期句柄上打转错误没有说明句柄已过期用旧句柄调用一次返回说明这一点的执行错误;把保留期写进创建工具
列表很大工具很多或 schema 很长INFO 行里的字节数删掉不影响选择的内容;顺序保持确定以便缓存
似乎缺工具列表分页了list-complete跟随 nextCursor,保存每一页

九、各修订版之间改了什么

每一行是官方文件里首次出现该项的修订版。对选择有影响的 Tool 字段在 2025-11-25 与 2026-07-28 相同,只是 2025-11-25 还多一个 execution;逐字段的表见工具详解第二节。

项目首次出现的修订版变更日志
工具 annotations2025-03-26“Added comprehensive tool annotations”
工具 title2025-06-18“Add title field for human-friendly display names”
工具 outputSchema、structuredContent2025-06-18“structured tool output”
工具 icons2025-11-25“expose icons as additional metadata for tools”
工具名称指引2025-11-25“Provide guidance on tool names (SEP-986)”
输入校验错误作为工具执行错误2025-11-25“to enable model self-correction (SEP-1303)”
确定的工具顺序;不因连接而异2026-07-28“Servers SHOULD return tools from tools/list in a deterministic order”

十、上线前核对

  • 每个名称唯一、风格统一,并写明它操作哪个对象;检查脚本没有 FAIL。
  • 每条描述都写明工具做什么、何时用;每个参数都有描述或 enum;声明了 required。
  • 注解如实设置;破坏性的工具单独成工具。
  • 模型能自行修正的每种失败,错误文字都写明规则与合法形式。
  • 用用户的话写的任务文件,在检查脚本和你自己的模型上都跑得干净。
  • 列表顺序确定、内容完整,并量过字节数。

版本与验证说明

规范文字与 schema 都取自官方仓库的同一个固定提交(dae694f6,含 2026-07-28 修订版及之前四个修订版)。检查脚本在 macOS 上用 Python 3.14 运行;实时列表来自官方 Python SDK 2.3.0(mcp==2.3.0)。三套工具定义是为这篇教程手写的样例,用来展示检查脚本能发现什么,并不代表真实服务器里这类定义有多常见。选择测试只是词重叠排序器,不能说明你的模型会怎么表现;第七节讲怎样把同一批任务交给模型去跑。

来源