Server Instructions 实战:规范版本差异与测试方法
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-25 | InitializeResult.instructions | 先发 initialize 请求,再发 notifications/initialized 通知 | 给模型的“提示”(hint),“可以”(MAY)加入系统提示词 |
| 2026-07-28 | DiscoverResult.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-Mini | 8/10(95% 区间 49–94%) | 2/10(6–51%) | 0.023 |
| Claude Sonnet-4 | 9/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 | 同上 |
| 用户有意发起的工作流 | Prompt | Prompts 由用户控制,必须由用户选择 |
| 体量大或按任务变化的参考资料 | Resource | Resources 由客户端挂载,不要塞进每次会话的系统提示词 |
| 绝不能被跳过的事(安全、隐私、保护性的调用顺序) | 服务器代码或宿主 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 服务器覆盖,我只用一个伪造的对端验证过,所以请视为测试程度较低的部分。
七、最小评测流程
- 像官方文章那样,把成败判据写成工具调用序列。主观打分无法规模化。
- 在你关心的每个宿主和模型上,用同一批任务分别测“有”和“没有”instructions。
- 保留几次会话的宿主请求日志,证明文本确实被注入了。
- 报告计数加区间或检验结果,不要只给一个百分比,并参照第三节确定样本量。
- 宿主、模型或工具集变化后重新测。
八、上线前自查
- 文本里只出现真实存在的工具、Prompt 和 Resource(跑一遍检查脚本)。
- 没有任何一句在重复工具描述,而且短到几秒钟就能读完。
- 其中没有哪条规则单独承担数据或安全保护;那些规则写在代码里。
- 你有意识地选了
ttlMs,也清楚自己怎样服务旧版客户端。
参考来源
- Server Instructions: Giving LLMs a user manual for your server(官方博客,A/B 计数来源)
- 2026-07-28 规范中的 Discovery、Caching、stdio 传输与版本管理
- 各规范版本的
schema.ts,见官方仓库 - 本站相关阅读:掌握 MCP 工具开发,以及 MCP Apps 实战(其检查脚本复用了本脚本的 stdio 探测)