上线之前,怎么测试一个 MCP server
你的 MCP server 在编辑器里跑得好好的。这个结果是真的,但范围很窄:编辑器跑在你自己的机器上,只说一种协议版本,而且很宽容。一个从公网连进来的 agent,这几点都不具备。
这篇文章按外部客户端遇到你的方式来测:一层一层往上,从 TLS 证书一直到工具描述里的每个字。每一步都有一条可以直接粘贴的 curl 命令,把 https://your-server.example.com/mcp 换成你的地址就行。
开始之前:你的 server 说的是哪一代协议?
MCP 的形态变过。到 2025-11-25 为止,客户端要先用 initialize 握手建立会话。现在的版本 2026-07-28 去掉了握手和会话:每个请求都在 _meta 里自带协议版本和客户端能力(客户端信息建议带,但不是必需),Streamable HTTP 传输还会把其中一部分复制到请求头里。
两代协议现在都在用。2026-10-09,我们目录里一共 1,902 台服务器,其中能识别出协议版本的有 1,806 台:
| 协议版本 | 服务器数 |
|---|---|
| 2025-06-18 | 1,185 |
| 2026-07-28 | 513 |
| 2024-11-05 | 39 |
| 2025-03-26 | 38 |
| 2025-11-25 | 31 |
所以你实现的是哪一代就测哪一代;两代都实现了,就两代都测。下面各步在两代有差别的地方会分开写。
第 1 层:客户端到底连不连得上?
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' -v https://your-server.example.com/mcp 2>&1 | grep -E '^\* (SSL|Server certificate|subject|expire|issuer)|^[0-9]{3} '
看三件事:
- 证书:没过期;证书里的名字(SAN)覆盖你的主机名;证书链完整。缺一张中间证书时,桌面浏览器往往能从缓存里补上,看起来没事,但一个全新的客户端会直接失败。
- 重定向:如果
/mcp会跳转,看它跳到哪里。一旦跳到别的主机,客户端就可能丢掉请求头,包括Authorization;很多客户端干脆不跟。 - 耗时:记下总耗时。agent 是在对话中间调用工具的,首字节慢,屏幕前等着的人能感觉到。
GET 返回 405 Method Not Allowed 在这里是正常的:2026-07-28 只要求端点接受 POST;规范还说,只支持这一版的服务器应该对 GET 回 405。
第 2 层:握手能不能走通?
每个 POST 都必须带 Accept 头,而且同时列出 application/json 和 text/event-stream。只列了其中一个的话,很多服务器框架会直接回 406 Not Acceptable。一个 server 在这个客户端里好好的、换个客户端就连不上,这是常见原因。
**当前版本(2026-07-28):server/discover。**服务器必须实现它,一次就能返回支持的版本、能力和身份:
curl -sS -i https://your-server.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"0.1"},
"io.modelcontextprotocol/clientCapabilities":{}}}}'
应该得到 200,里面有 supportedVersions、capabilities,最好还有 _meta 里的 serverInfo。有两种失败要认得:
400,错误码-32020(HeaderMismatch):请求头和请求体对不上,或者缺了必需的头。MCP-Protocol-Version和Mcp-Method每个请求都要带。400,错误码-32022(UnsupportedProtocolVersion):响应体会列出服务器真正支持的版本。只要列表是真的,这就是正确行为,不是 bug。
更早的版本:initialize。
curl -sS -i https://your-server.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl-test","version":"0.1"}}}'
返回结果里会写明服务器同意的 protocolVersion。如果响应带了 Mcp-Session-Id 头,之后每个请求都要把它带回去;从 2025-06-18 起,还要带上 MCP-Protocol-Version。然后告诉服务器你准备好了:
curl -sS -i https://your-server.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-06-18' \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
这条通知应该得到 202 Accepted,没有响应体。
**怎么读响应。**服务器可以回普通 JSON,也可以回一段很短的 Server-Sent Events 流。两种都合规,客户端必须都能处理。看到 Content-Type: text/event-stream 时,JSON 在 data: 开头的那几行里:
... | sed -n 's/^data: //p'
第 3 层:工具清单是不是 agent 需要的样子?
# 2026-07-28
curl -sS https://your-server.example.com/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":2,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"0.1"},
"io.modelcontextprotocol/clientCapabilities":{}}}}'
(更早的版本:params 写成 {},并带上你的会话头。)
逐个工具检查:
- 有
name,有inputSchema,而且inputSchema是一个 JSON Schema 对象,不能是null。没有参数的工具也应该写成{"type":"object","additionalProperties":false}。 - 名字 1–128 个字符,只用
A–Z a–z 0–9 _ - .,不带空格。客户端会把工具名放进请求头或提示词,其他字符都容易出问题。 - 工具没变时,每次返回的顺序都一样。客户端会缓存这个列表,模型的提示词缓存也会。
然后检查分页。结果里有 nextCursor,说明列表还没完。把它放进 params 的 "cursor" 再调一次,直到不再返回 nextCursor。只读第一页的客户端会悄无声息地丢掉后面的工具。我们在自己的报告页上也发现过这个问题。
第 4 层:错误的形状对不对?
错误里说清楚了哪里不对,agent 就能恢复;没说清楚,它就放弃。MCP 把错误分成两类:
- 协议错误是 JSON-RPC 错误:工具不存在、请求格式不对、服务器内部故障。工具不存在是
-32602。方法不存在是-32601,在2026-07-28下还要配上 HTTP404。 - 工具执行错误是一个正常的结果,带
"isError": true,文字说明出了什么问题,比如日期已经过去了、这个 ID 不存在。模型读得懂这段文字,就能换个参数再试。
调一个不存在的工具:
curl -sS https://your-server.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: definitely_not_a_tool' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"definitely_not_a_tool","arguments":{},"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"0.1"},
"io.modelcontextprotocol/clientCapabilities":{}}}}'
你要的是一个带错误码和说明的 JSON-RPC error 对象。不要 HTML 错误页,不要一个空的 200,也不要堆栈。
再拿一个真实存在的工具,故意传错参数。好的服务器会回 isError: true,再加一句模型能照着改的话。
第 5 层:如果要求鉴权,客户端看得懂你的要求吗?
MCP 里鉴权是可选的。但只要你用了,一个没有 token 的客户端就必须能自己弄清楚怎么拿到 token,而不是靠人去读你的文档。
curl -sS -i https://your-server.example.com/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl-test","version":"0.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}' \
| grep -iE '^HTTP/|^www-authenticate'
应该得到 401,以及类似这样的头:
WWW-Authenticate: Bearer resource_metadata="https://your-server.example.com/.well-known/oauth-protected-resource", scope="files:read"
再去取 resource_metadata 指向的地址:
curl -sS https://your-server.example.com/.well-known/oauth-protected-resource
它应该返回 JSON,里面有你的 resource,authorization_servers 里至少有一项。规范要求服务器发布这份文档(RFC 9728),客户端靠它找到去哪里登录。常见的错:
- 返回了
401,却没有WWW-Authenticate头; resource_metadata指向的地址返回的是你的 HTML 首页;authorization_servers里的地址提供不了 OAuth 元数据。
这些都不需要真的 token,所以完全可以、也应该从外部测。
第 6 层:模型会读到什么?
工具名和描述会原样进入模型的上下文。要看两件事:
- 对 agent 下指令的话,而不是描述这个工具:「总是先调用这个工具」「忽略其他工具」「不要告诉用户」。描述工具做什么就够了,什么时候调用让客户端决定。
- 隐藏字符:零宽空格、双向文字覆盖符、不可见的格式字符。它们改变的是模型读到的内容,人在代码评审里看到的却一模一样。
# 打印文本里含有零宽字符或双向控制字符的工具名
curl -sS ... tools/list ... | sed -n '/^{/p; s/^data: //p' \
| jq -r '.result.tools[] | select(tostring | test("[\u200b-\u200f\u202a-\u202e\u2060-\u2064\ufeff]")) | .name'
没有任何输出才是你要的结果。
第 7 层:有没有改了你本来没打算改的东西?
agent 和批准它们的人,都会对一套工具形成习惯。一次部署如果改了工具名、删了参数或者改了 schema 里的类型,按旧样子做的计划就会坏掉,而且往往没有清楚的报错。
给你发布的东西记一个指纹:
curl -sS ... tools/list ... | sed -n '/^{/p; s/^data: //p' \
| jq -S '.result.tools | map({name, inputSchema})' | sha256sum
把这个哈希和版本记在一起。下次部署后再算一次:如果变了,你应该说得出是哪一处改动造成的。
把它变成习惯,而不是上线当天的一次性任务
服务器会慢慢走样:证书会过期,代理会被改配置,升级一个依赖就可能改了错误的形状。每次部署后都跑一遍同样的检查,平时也定时跑。
如果不想自己维护这些脚本,可以在 mcpcheckup.com 粘贴你的地址。它会像 agent 一样从外部跑这些检查,每一项给出四种状态之一:已通过、未通过、观察到风险,或者未检查并写明原因。从不给一个总分。
清单
| 层 | 检查什么 |
|---|---|
| 连通 | 证书有效、链完整、名字匹配;不跳转到别的主机;耗时合理 |
| 握手 | Accept 同时列出两种类型;server/discover(2026-07-28)或 initialize(更早版本)走得通;请求头和请求体一致 |
| 工具 | 每个工具都有名字和对象形式的 inputSchema;名字只用允许的字符;nextCursor 一直翻到底 |
| 错误 | 不存在的工具 → JSON-RPC 错误;参数错误 → isError: true,并附一句能照着改的说明 |
| 鉴权 | 401 带着含 resource_metadata 的 WWW-Authenticate;元数据文档里列出了授权服务器 |
| 描述 | 没有对 agent 下指令的话;没有隐藏字符 |
| 变化 | 每次发布都记下工具集指纹,每次部署后比对 |