为什么我的 MCP server 显示"已观测,未签名"?

2026-09-14MCP Checkup

报告页标着已观测,未签名,意思是 MCP Checkup 对你的 server 跑了检查,并且原样展示看到的东西——但这次运行还没有附带签名证明,第三方因此没有可以独立验证的对象。(“已观测,未签名”的一般含义见方法论页——未签名的运行不会被渲染成通过/未通过的结论。)一次运行也可能因为我们这边的原因而未签名——签名服务故障,或者被我们自己的检查取消了发布资格;遇到这种情况,报告页会直接写明。本文只讲协议检查层面的原因——也就是你自己能处理的那些。如果 discovery_handshake、protocol_revision、tools_list 这几项协议检查落在了你没预料到的状态,原因几乎总是下面五种之一。按自己动手最容易核对的顺序排列:它长什么样、怎么对你自己的 server 自测、以及能修时的修法。

自测之前先说一句:下面的 curl 用的是旧版 initialize 握手(协议修订 2025-06-18 及更早)。如果你的 server 实现的是当前的 2026-07-28 修订——它用 server/discover 取代了 initialize——就改跑等价的 server/discover 请求:我们的探测器总是先试 server/discover,只在收到 4xx 时才回退到 initialize。

curl -s -w "\nHTTP %{http_code}\n" https://your-server.example/mcp \
-X POST \
-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":"probe","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'

1. 你的鉴权中间件把 initialize 也拦了

第一个要查的。很多鉴权中间件把整个 MCP endpoint 罩在同一道检查后面,连本该无凭据可达的握手方法也一起拦掉。

curl -s -o /dev/null -w "%{http_code}\n" https://your-server.example/mcp \
-X POST \
-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":"probe","version":"1.0"}}}'

如果返回 401 或 403——在到达你的 JSON-RPC 处理之前就被鉴权层拒绝了——就是这个原因。我们在 2026 年 8 月底的 droproom/mcp 上亲眼见过:维护者的鉴权中间件对所有请求路径一视同仁,initialize 也在内。(同一个 server 在下面的原因 5 里又出现一次,是在这次修复之后,原因与此无关,而且是我们的问题,不是他们的。)

改动之前有一条提醒:这个修法只针对本来就打算公开可发现的 server。如果你是有意把工具发现放在鉴权后面——比如一个内部 server,它的工具名、描述、schema 不应该让匿名调用者看到——那是正当的访问控制选择,不是协议缺陷。我们的匿名探测器现在会如实报告这种情况:当决定握手结果的那个响应是带有结构合法 WWW-Authenticate 挑战的 401 时,发现类检查记为未检查、原因是需要凭据,而不是记为失败。我们从不发送凭据,所以“未检查”就是字面意思——我们看不到你的门后面,这与说门坏了不是一回事。没有结构合法挑战的 401 不算凭据门;而第一个请求就返回可识别的 MCP 协议错误码,仍然被读作“一个现代 server 拒绝了这个请求”,而不是在索要凭据。如果你的 server 回的是真正的鉴权挑战,把这条原因当成预期行为,不用改。

修法(针对打算公开可发现的 server):放行只读握手方法——server/discover、initialize、tools/list、notifications/initialized——不需鉴权。这四个是客户端(或像我们这样的检查器)在知道你的 server 提供什么之前必须能调用的只读协议方法。tools/call 保持在你现有的鉴权后面,一点不动;调用者实际能执行什么,不因此改变。

2. 你的 server 只实现了 SSE,没有 Streamable HTTP POST

当前修订和旧修订都通过对 MCP endpoint 的普通 HTTP POST 传输握手。按旧的 SSE 传输方式构建的 server 不会以同样的方式接受它。

curl -s -o /dev/null -w "%{http_code}\n" https://your-server.example/mcp \
-X POST \
-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":"probe","version":"1.0"}}}'

这条请求返回 404 或 405,或者 server 只响应对一个 SSE 形态 endpoint 的 GET,指向这里。

修法:在现有 SSE endpoint 之外(或替代它)加上 Streamable HTTP POST 传输。多数语言的 MCP SDK 已经实现了这个传输——检查一下你的 server 用的 SDK 版本是不是老到还没有它。

3. 你的 initialize 响应缺 protocolVersion

initialize 响应体的 result 里需要有 protocolVersion 字段——一些手写(非 SDK)的实现会漏掉它。

curl -s https://your-server.example/mcp \
-X POST \
-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":"probe","version":"1.0"}}}' \
| jq '.result.protocolVersion'

如果打印出带引号的版本字符串,字段存在,原因 3 不适用。如果打印 null,你的 initialize 响应就缺了 result.protocolVersion。如果 jq 报错,先看响应的 Content-Type:是 text/event-stream 的话,响应是 SSE 分帧的,我们的探测器会先解帧再读,你需要从 data: 行里取出 JSON 再查这个字段;不是的话,响应体不是 JSON,我们同样读不到它。我们的探测器要求在 HTTP 200 响应的 result 对象里、恰好这个路径上有一个字符串。出现在响应体其他位置的 protocolVersion(比如在某个 error 对象里)不算。

修法:在 initialize 响应的 result 对象里加上 protocolVersion,回显你愿意使用的版本(不必与客户端请求的版本完全一致——不一致时由客户端决定怎么办)。

4. 会话头的顺序

少见,也更难自诊:有些实现在请求序列(initialize → notifications/initialized → tools/list)中对会话相关的头处理有误。

自测:对照你的 server 在这个序列中期望和返回的会话头。

这里没有一行就能改好的修法——如果前三个原因都不匹配你看到的情况,请联系我们并附上你 server 的标识,我们会看具体那次运行。

5. 可能根本不是你 server 的问题

这是列表里唯一一个不需要你动手的情况。

一个完全符合规范的旧版 server(按 2025-06-18 或更早修订构建)根本不知道当前修订的 server/discover 握手是什么——那是一个你的 server 从未被要求实现的新方法。旧版 server 收到不认识的方法时,预期行为是回某个 4xx 错误,而检查器应当把那里的 4xx(限流用的 429 和下文自测里的三个现代错误码除外)当作“这是旧版 server,回退到旧 initialize 流程”——不是只认某一个特定状态码。

我们有一版探测器把这条收得比应该的窄:它只把精确的 HTTP 400 当作回退信号。在 droproom/mcp 上——一个完全合规的旧版实现,它对未识别方法的回退响应恰好是 401、响应体为 {"error":"unauthorized"}(不是 JSON-RPC 形态,因为根本没走到那一步)——我们的探测器没有识别出回退条件,把 discovery_handshake、protocol_revision、tools_list 标成了失败,另有五项检查因此被跳过。官方 MCP SDK 客户端对着同一个 server,顺利完成了 initialize → notifications/initialized → tools/list 全部序列。

我们在 2026-08-30 修复了这个问题:回退条件现在接受 400–499 范围内除 429(限流)以外的状态,与规范自己对旧版 server 兼容性的指引一致。

自测:对你的 endpoint 跑 server/discover 请求,同时看状态码和响应体。回退情形是:状态在 400–499 范围内且不是 429,并且响应体不是错误码为 -32020、-32021、-32022 的 JSON-RPC 错误——这三个码表示现代握手到达了你的 server 而你的 server 有意拒绝了请求(那是真正的客户端/服务端不匹配,要按它自己的方式修,不是回退情形)。如果你的回退响应符合上面这个形态——一个不是 429 的 4xx,加一个非现代错误的响应体——而且原因 1–3 的 initialize 自测都正常,原因 4 的完整序列(initialize → notifications/initialized → tools/list)里的会话头也核对过,却在 2026-08-30 之后仍看到这三项检查失败,请告诉我们:那很可能是我们的探测器回归了。


五个都不像你看到的情况?请联系我们,附上你 server 的标识,我们会看具体那次运行。