Server Instructions 实战:规范版本差异与测试方法

Server Instructions 实战:规范版本差异与测试方法

October 25, 2026·MCP中文社区·技术解析·MCP, Server Instructions, server/discover, 测试, 工具设计

MCP 官方博客文章 Server Instructions: Giving LLMs a user manual for your server 论证了 instructions 字段的价值。本文从那篇文章停下的地方开始,回答实现者实际会遇到的五个问题:各规范版本里这个字段通过什么渠道送达;已发布的证据能支撑多大的结论;每类知识该放在哪里;实际使用中会在哪里出问题;以及怎样用脚本检查一台真实运行的服务器。

文中的规范事实,核对自 2026 年 10 月官方仓库的 schema 文件与规范页面;实验在本地用官方 Python SDK 2.3.0 完成,并在文中标明。

一、字段落点:按规范版本对照

规范版本所在位置客户端如何拿到schema 中的措辞
2024-11-05、2025-03-26、2025-06-18、2025-11-25InitializeResult.instructions先发 initialize 请求,再发 notifications/initialized 通知给模型的“提示”(hint),“可以”(MAY)加入系统提示词
2026-07-28DiscoverResult.instructions发送带逐请求 _meta 的 server/discover 请求应聚焦于帮助模型有效使用服务器的信息,且“不应重复工具描述里已有的信息”

2026-07-28 的文本带来两个推论:

  • instructions 只随 server/discover 送达。 服务器必须(MUST)实现它,但客户端可以(MAY)跳过,直接内联调用任意方法。一个从不调用 server/discover 的现代客户端永远看不到你的 instructions,所以它不能成为唯一的保障。
  • 结果可缓存。 服务器必须在 resultType 为 complete 的 server/discover 结果里给出 ttlMs(不小于 0 的整数)和 cacheScope。TTL 只是新鲜度提示:在它过期前,客户端可以继续使用旧文本。schema 里没有针对 instructions 的通知(2026-07-28 的通知包括 cancelled、message、progress、prompts/tools/resources 的列表变更、resources/updated 以及 subscriptions/acknowledged),所以改动后的文本只能在 TTL 到期或客户端主动重新获取时才到达有缓存的客户端。

官方博客点名的反模式“重复工具描述”,现在已经写进 schema 的字段说明,不再只是博客建议。

二、客户端怎样判断对面是哪个时代的服务器

在 stdio 上,同时支持两个时代的客户端先发 server/discover,再按应答判断。规范给出三种结果(stdio:向后兼容):

对 server/discover 的应答服务器时代客户端动作
DiscoverResult现代从 supportedVersions 选版本,从结果里读 instructions
错误 -32022(不支持的协议版本),带 data.supported现代,但版本不同换一个列出的版本重试,不要回退到 initialize
其他任何错误(常见 -32601 或 -32602),或超时无应答旧版发 initialize,再发 notifications/initialized,从 InitializeResult 读 instructions

规范特别强调:回退不能只认某一个错误码。第六节的脚本就是照这张表实现的,所以对两个时代的服务器都能用。

旧版客户端连上只支持现代版本的服务器会直接失败:对它来说 initialize 是未知方法,规范要求(SHOULD)这类服务器在错误里写明自己支持的版本。这种情况下,你的 instructions 根本送不到。

三、已发布的证据能说明多少

官方文章只测了一个任务(在 VS Code 里用 GitHub MCP 服务器评审 Pull Request),判据很清晰:成功是依次调用 create_pending_pull_request_review、add_comment_to_pending_review、submit_pending_pull_request_review。每个格子 10 次会话。下表在官方计数基础上,补充了我计算的精确双侧 Fisher 检验与 95% Wilson 区间:

模型有 instructions没有Fisher p
GPT-5-Mini8/10(95% 区间 49–94%)2/10(6–51%)0.023
Claude Sonnet-49/10(60–98%)10/10(72–100%)1.000
合并17/20(64–95%)12/20(39–78%)0.155

这些数据支持什么,不支持什么:

  • GPT-5-Mini 的差距大到不太可能是偶然,但两个区间仍有少许重叠。Claude 的结果无法区分 9/10 与 10/10。
  • 合并后的“+25 个百分点”是把两个模型按 1:1 混在一起,在 5% 水平上与噪声无法区分。把它当成普遍提升来引用,会夸大证据。更稳妥的读法就是官方文章自己的结论:有的模型本来就走最优路径,有的不会,所以要按宿主和模型分别测试。
  • 样本量很关键。基线 20% 时,每组 10 次会话只有做到 8/10 才能达到 p < 0.05(7/10 的 p 是 0.070);每组 30 次时,16/30 对 6/30 就已经是 p = 0.015。

四、每类知识该放哪里

规范的控制层级(Prompts 由用户控制、Resources 由应用控制、Tools 由模型控制)决定了大部分归属:

想让模型知道放在原因
某个工具做什么、参数和结果工具的 description 与 schema模型选择该工具时会读到它
工具之间的顺序与依赖(“先认证再搜索或读取”)、跨服务器提示instructions关系类信息是任何单个描述都表达不了的
跨工具的限制(共享的限流、工作区边界)instructions同上
用户有意发起的工作流PromptPrompts 由用户控制,必须由用户选择
体量大或按任务变化的参考资料ResourceResources 由客户端挂载,不要塞进每次会话的系统提示词
绝不能被跳过的事(安全、隐私、保护性的调用顺序)服务器代码或宿主 hooks官方文章明确提醒:关键动作不要依赖 instructions
语气、人设、宣传语哪里都不放官方文章列为反模式

五、实际会在哪里出问题

现象可能原因如何发现处理
模型表现得像没有 instructions宿主没有注入;schema 只说“可以”加入系统提示词在宿主的对话或请求日志里找注入的文本逐个宿主测试,别让正确性依赖这个字段
模型调用已不存在的工具,或绕开改名后的工具工具名与文本脱节脚本报 unknown-reference在 CI 里跑检查脚本
instructions 把每个工具又讲了一遍从描述里复制粘贴;浪费 token、稀释信号脚本报 duplicates-tool-description(启发式)单个工具的细节留在描述里
instructions 越写越长,变成手册每出一条新规则就追加一句脚本报 too-long(默认 1500 字符是我的经验阈值,不是规范值)删掉模型本来就会遵守的规则
改了 instructions 却没生效server/discover 可缓存,ttlMs 只是提示对照 ttlMs 与你需要多快生效有意识地设定 ttlMs,接受最多一个 TTL 的滞后
现代客户端里看不到 instructions它根本没调用过 server/discover抓取该客户端发出的最初几个请求不要只靠这个字段
旧版客户端连不上服务器只支持现代版本,拒绝了 initialize对它跑一次旧版握手同时支持两个时代,或提示用户升级
stdio 握手失败stdout 上输出了 MCP 消息以外的内容脚本报 stdout-noise日志写到 stderr

六、检查一台真实的服务器

check_instructions.py 只依赖 Python 标准库。它启动一个 stdio 服务器,执行第二节的探测,列出工具,并检查 instructions。检查规则是工程经验上的启发式,不是规范要求。退出码 0 表示没有发现,1 表示有警告,2 表示有错误。

check_instructions.py
#!/usr/bin/env python3
"""Probe an MCP stdio server and lint its `instructions` field (stdlib only).

Era detection follows the 2026-07-28 stdio "Backward Compatibility" rules:
send `server/discover` first; a DiscoverResult or error -32022 means a modern
server, any other error or a timeout means a legacy server, so fall back to
the `initialize` handshake. The lint rules are engineering heuristics, not
requirements of the specification.

Usage: check_instructions.py [--timeout S] [--max-chars N] -- <server command>
Exit code: 0 = no findings, 1 = warnings, 2 = errors (cannot connect, bad field).
"""
import argparse
import json
import queue
import re
import subprocess
import sys
import threading

MODERN = "2026-07-28"
LEGACY = "2025-11-25"
UNSUPPORTED_VERSION = -32022
CLIENT = {"name": "check_instructions", "version": "0.1"}
STOP = {"the", "a", "an", "to", "for", "of", "by", "in", "on", "is", "are", "and",
        "or", "this", "that", "with", "it", "tool", "tools", "server"}


class Peer:
    """A stdio JSON-RPC peer: one JSON message per line in both directions."""

    def __init__(self, cmd, timeout):
        self.timeout, self.next_id, self.noise = timeout, 0, 0
        self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                     stderr=subprocess.DEVNULL, text=True)
        self.lines = queue.Queue()
        threading.Thread(target=self._pump, daemon=True).start()

    def _pump(self):
        for line in self.proc.stdout:
            self.lines.put(line)
        self.lines.put(None)

    def send(self, method, params=None, notify=False):
        msg = {"jsonrpc": "2.0", "method": method}
        if params is not None:
            msg["params"] = params
        if not notify:
            self.next_id += 1
            msg["id"] = self.next_id
        try:
            self.proc.stdin.write(json.dumps(msg) + "\n")
            self.proc.stdin.flush()
        except (BrokenPipeError, OSError):
            pass
        return msg.get("id")

    def call(self, method, params=None):
        rid = self.send(method, params)
        while True:
            try:
                line = self.lines.get(timeout=self.timeout)
            except queue.Empty:
                return {"timeout": True}
            if line is None:
                return {"closed": True}
            try:
                msg = json.loads(line)
            except ValueError:
                self.noise += 1  # stdout must carry MCP messages only
                continue
            if isinstance(msg, dict) and msg.get("id") == rid and ("result" in msg or "error" in msg):
                return msg

    def close(self):
        try:
            self.proc.stdin.close()
            self.proc.wait(timeout=3)
        except Exception:
            self.proc.kill()


def meta(version, capabilities=None):
    return {"io.modelcontextprotocol/protocolVersion": version,
            "io.modelcontextprotocol/clientInfo": CLIENT,
            "io.modelcontextprotocol/clientCapabilities": capabilities or {}}


def handshake(peer, capabilities=None):
    """Return (era, protocol version, result dict) or (None, None, error text)."""
    version = MODERN
    for _ in range(2):
        reply = peer.call("server/discover", {"_meta": meta(version, capabilities)})
        result = reply.get("result")
        if isinstance(result, dict) and "supportedVersions" in result:
            return "modern", version, result
        error = reply.get("error") or {}
        if error.get("code") == UNSUPPORTED_VERSION:  # modern server: never fall back
            usable = [v for v in (error.get("data") or {}).get("supported", []) if v >= MODERN]
            if not usable:
                return None, None, "modern server offers no modern protocol version"
            version = max(usable)
            continue
        break  # any other error, or a timeout: treat as legacy
    reply = peer.call("initialize", {"protocolVersion": LEGACY, "capabilities": capabilities or {},
                                     "clientInfo": CLIENT})
    if not isinstance(reply.get("result"), dict):
        return None, None, "initialize failed: %s" % json.dumps(reply)[:200]
    peer.send("notifications/initialized", notify=True)
    return "legacy", reply["result"].get("protocolVersion"), reply["result"]


def list_tools(peer, era, version, capabilities=None):
    """Return {name: tool object}, or None if tools/list is unavailable."""
    tools, cursor = {}, None
    for _ in range(20):  # pagination guard
        params = {"_meta": meta(version, capabilities)} if era == "modern" else {}
        if cursor:
            params["cursor"] = cursor
        result = peer.call("tools/list", params).get("result")
        if not isinstance(result, dict):
            return tools or None
        for tool in result.get("tools", []):
            tools[tool["name"]] = tool
        cursor = result.get("nextCursor")
        if not cursor:
            break
    return tools


def stem(word):
    """Strip a plural ending: searches -> search, files -> file, reads -> read."""
    if len(word) <= 3:
        return word
    if re.search(r"(ss|ch|sh|x|z)es$", word):
        return word[:-2]
    return word[:-1] if word.endswith("s") and not word.endswith("ss") else word


def words(text):
    return {stem(w) for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP}


def lint(text, tools, max_chars):
    findings = []
    if len(text) > max_chars:
        findings.append(("WARN", "too-long", "%d chars > %d; instructions are not a manual" % (len(text), max_chars)))
    if tools is None:
        return findings
    for ref in sorted(set(re.findall(r"""['"`]([A-Za-z][\w.\-]*\*?)['"`]""", text))):
        known = any(n.startswith(ref[:-1]) for n in tools) if ref.endswith("*") else ref in tools
        if not known:
            findings.append(("WARN", "unknown-reference",
                             "'%s' is not in tools/list (%s)" % (ref, ", ".join(sorted(tools)) or "no tools")))
    for sentence in re.split(r"(?<=[.!?])\s+|\n", text):
        sw = words(sentence)
        if len(sw) < 2:
            continue
        for name, tool in tools.items():
            if len(sw & words(tool.get("description", ""))) / len(sw) >= 0.8:
                findings.append(("WARN", "duplicates-tool-description",
                                 "\"%s\" restates the description of '%s'" % (sentence.strip(), name)))
                break
    return findings


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--timeout", type=float, default=5.0)
    ap.add_argument("--max-chars", type=int, default=1500)
    ap.add_argument("cmd", nargs=argparse.REMAINDER)
    args = ap.parse_args()
    cmd = args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd
    if not cmd:
        ap.error("missing server command after --")
    peer = Peer(cmd, args.timeout)
    try:
        era, version, result = handshake(peer)
        if era is None:
            print("[ERROR] %s" % result)
            return 2
        tools = list_tools(peer, era, version)
    finally:
        peer.close()
    extra = " ttlMs=%s cacheScope=%s" % (result.get("ttlMs"), result.get("cacheScope")) if era == "modern" else ""
    print("era: %s (protocol %s)%s" % (era, version, extra))
    print("tools: %s" % (", ".join(sorted(tools)) if tools is not None else "unavailable"))
    findings = []
    text = result.get("instructions")
    if text is None:
        print("instructions: absent (optional field)")
    elif not isinstance(text, str):
        findings.append(("ERROR", "bad-type", "instructions must be a string"))
    else:
        print("instructions: %d chars" % len(text))
        findings += lint(text, tools, args.max_chars)
    if peer.noise:
        findings.append(("WARN", "stdout-noise", "%d non-JSON line(s) on stdout" % peer.noise))
    for level, code, message in findings:
        print("[%s] %s: %s" % (level, code, message))
    if not findings:
        print("[OK] no findings")
    return 2 if any(f[0] == "ERROR" for f in findings) else 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())

为了有东西可测,下面是一个基于官方 Python SDK(pip install mcp)的小服务器。两个环境变量开关用来模拟第五节的失效情形。

demo_server.py
"""Demo server on the official Python SDK (pip install mcp) with server instructions.

DEMO_RENAME_READ=1 registers `read` as `read_file` while the instructions still
say 'read', which simulates tool-name drift after a refactor.
DEMO_PARROT=1 swaps in instructions that restate tool descriptions.
"""
import os

from mcp.server.mcpserver import MCPServer

INSTRUCTIONS = (
    "Call 'authenticate' before 'search' or 'read'. "
    "Use 'search' before 'read' to validate file paths. "
    "Rate limit: 100 requests/minute across all tools."
)
if os.environ.get("DEMO_PARROT"):
    INSTRUCTIONS = "The search tool searches for files. The read tool reads files."

server = MCPServer("demo-files", instructions=INSTRUCTIONS)


@server.tool()
def authenticate(token: str) -> str:
    """Authenticate the session with an API token."""
    return "ok"


@server.tool()
def search(query: str) -> list[str]:
    """Search for files whose names match the query."""
    return []


@server.tool(name="read_file" if os.environ.get("DEMO_RENAME_READ") else "read")
def read(path: str) -> str:
    """Read a file by path."""
    return ""


if __name__ == "__main__":
    server.run("stdio")

SDK 服务器两个时代都能应答,所以要验证旧版回退,需要一个会拒绝 server/discover 的替身。下面这个标准库实现的桩服务器就是干这个的:--silent 让它对未知请求不作应答而不是报错,--noise 会在 stdout 上先打印一行横幅。

legacy_server.py
"""Minimal legacy-era (initialize handshake) stdio server, stdlib only.

Used to exercise the checker's fallback paths:
  --silent  never answer requests it does not know (the probe must time out)
  --noise   print a banner to stdout before serving (breaks stdio framing)
"""
import json
import sys

SILENT = "--silent" in sys.argv
if "--noise" in sys.argv:
    print("demo-files 0.1 starting...", flush=True)

TOOLS = [{"name": "ping", "description": "Check connectivity.", "inputSchema": {"type": "object"}}]


def reply(rid, result=None, error=None):
    msg = {"jsonrpc": "2.0", "id": rid}
    msg.update({"error": error} if error else {"result": result})
    print(json.dumps(msg), flush=True)


for line in sys.stdin:
    req = json.loads(line)
    method, rid = req.get("method"), req.get("id")
    if method == "initialize":
        reply(rid, {"protocolVersion": "2025-11-25", "capabilities": {"tools": {}},
                    "serverInfo": {"name": "legacy-demo", "version": "0.1"},
                    "instructions": "Call 'ping' once at startup."})
    elif method == "tools/list":
        reply(rid, {"tools": TOOLS})
    elif rid is not None and not SILENT:
        reply(rid, error={"code": -32601, "message": "Method not found"})

我本地的运行输出(Python 3.14、mcp 2.3.0、macOS):

$ python3 check_instructions.py -- python3 demo_server.py
era: modern (protocol 2026-07-28) ttlMs=0 cacheScope=private
tools: authenticate, read, search
instructions: 147 chars
[OK] no findings

$ DEMO_RENAME_READ=1 python3 check_instructions.py -- python3 demo_server.py
era: modern (protocol 2026-07-28) ttlMs=0 cacheScope=private
tools: authenticate, read_file, search
instructions: 147 chars
[WARN] unknown-reference: 'read' is not in tools/list (authenticate, read_file, search)

$ DEMO_PARROT=1 python3 check_instructions.py -- python3 demo_server.py
era: modern (protocol 2026-07-28) ttlMs=0 cacheScope=private
tools: authenticate, read, search
instructions: 62 chars
[WARN] duplicates-tool-description: "The search tool searches for files." restates the description of 'search'
[WARN] duplicates-tool-description: "The read tool reads files." restates the description of 'read'

$ python3 check_instructions.py -- python3 legacy_server.py
era: legacy (protocol 2025-11-25)
tools: ping
instructions: 28 chars
[OK] no findings

$ python3 check_instructions.py -- python3 legacy_server.py --noise
era: legacy (protocol 2025-11-25)
tools: ping
instructions: 28 chars
[WARN] stdout-noise: 1 non-JSON line(s) on stdout

这些运行说明了几点。SDK 服务器默认返回 ttlMs=0,意思是客户端可以每次都重新获取;只有在能接受旧文本时才应设置更长的值。静默桩(--silent,配合 --timeout 1)同样会经由超时分支走到旧版路径。-32022 的重试分支没有被 SDK 服务器覆盖,我只用一个伪造的对端验证过,所以请视为测试程度较低的部分。

七、最小评测流程

  1. 像官方文章那样,把成败判据写成工具调用序列。主观打分无法规模化。
  2. 在你关心的每个宿主和模型上,用同一批任务分别测“有”和“没有”instructions。
  3. 保留几次会话的宿主请求日志,证明文本确实被注入了。
  4. 报告计数加区间或检验结果,不要只给一个百分比,并参照第三节确定样本量。
  5. 宿主、模型或工具集变化后重新测。

八、上线前自查

  • 文本里只出现真实存在的工具、Prompt 和 Resource(跑一遍检查脚本)。
  • 没有任何一句在重复工具描述,而且短到几秒钟就能读完。
  • 其中没有哪条规则单独承担数据或安全保护;那些规则写在代码里。
  • 你有意识地选了 ttlMs,也清楚自己怎样服务旧版客户端。

参考来源