Why does my MCP server show "observed-unsigned"?

2026-09-01· updated 2026-09-02MCP Checkup

A report page marked observed, unsigned means MCP Checkup ran its checks against your server and is showing exactly what it saw — but no signed attestation is attached to the run yet, so there’s nothing for a third party to independently verify. (See the methodology page for what “observed, unsigned” means in general — signed or not, we never turn observations into a pass/fail verdict.) A run can also be unsigned for reasons on our side — a signing-service failure, or a publication our own checks disqualified; when that’s the case, the report page says so directly. This article covers the protocol-check causes — the ones you can act on yourself. If several protocol checks — discovery_handshake, protocol_revision, tools_list — are landing in a state you didn’t expect, the cause is almost always one of five things. Below is each one, roughly in the order they’re easiest to check yourself: what it looks like, a curl command you can run against your own server to confirm it, and the fix.

One note before the self-tests: the curl requests below use the legacy initialize handshake (protocol revision 2025-06-18 and earlier). If your server implements the current 2026-07-28 revision — which replaced initialize with server/discover — run the equivalent server/discover request instead: our own probe always tries server/discover first and only falls back to initialize on a 4xx.

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. Your auth middleware is blocking initialize too

The first thing to check. A lot of auth middleware wraps every MCP endpoint behind the same check, which also blocks the handshake methods that are supposed to be reachable without credentials.

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"}}}'

If that comes back 401 or 403 — rejected by the auth layer before it ever reaches your JSON-RPC handling — this is it. We saw this firsthand on droproom/mcp in late August 2026: its maintainer’s auth middleware protected every request path uniformly, initialize included. (It was fixed the same day — the same server appears again in cause 5 below, after this fix, for an unrelated reason that was ours, not theirs.)

One caveat before you change anything: this fix is for servers that are meant to be publicly discoverable. If you’ve deliberately put tool discovery behind authentication — say, an internal server whose tool names, descriptions, and schemas shouldn’t be visible to anonymous callers — that’s a legitimate access-control choice, not a protocol defect. Our anonymous probe now reports that case honestly: when the response that settles our handshake attempt is a 401 with a structurally valid WWW-Authenticate challenge, the discovery checks are recorded as unverified with a credential_required reason, not failed. We never send credentials, so “unverified” means exactly that — we couldn’t see past your gate, which is not the same as saying it’s broken. A 401 without a structurally valid challenge does not count as a credential gate, and a recognized MCP protocol error code on the very first request is still read as a modern server rejecting that request rather than asking for credentials. If your server answers with a real auth challenge, read this cause as expected behavior, not something to change.

Fix (for servers meant to be publicly discoverable): let the read-only handshake methods — server/discover, initialize, tools/list, and notifications/initialized — through unauthenticated — these four are the read-only protocol methods a client (or a checker like ours) needs before it can even know what your server offers. Keep tools/call behind your existing auth exactly as it is today; nothing about this changes what a caller can actually invoke.

2. Your server only implements SSE, not Streamable HTTP POST

Both the current and legacy revisions carry the handshake over a plain HTTP POST to your MCP endpoint. A server built against the older SSE-based transport won’t accept it the same way.

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"}}}'

A 404 or 405 on that exact request, or a server that only responds to a GET against an SSE-shaped endpoint, points here.

Fix: add the Streamable HTTP POST transport alongside (or instead of) your existing SSE endpoint. The MCP SDKs for most languages implement this transport already — check whether your server is on an SDK version old enough to predate it.

3. Your initialize response is missing protocolVersion

The initialize response body needs a protocolVersion field in its result — some hand-rolled (non-SDK) server implementations omit it.

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'

If that prints a quoted version string, the field is present and cause 3 doesn’t apply. If it prints null — or jq fails because the body isn’t JSON — your initialize response is missing result.protocolVersion: our probe requires a string at exactly that path, in the result object of an HTTP 200 response. A protocolVersion that appears anywhere else in the body (say, inside an error object) doesn’t count.

Fix: add protocolVersion to the result object of your initialize response, echoing back the version you’re willing to speak (it doesn’t have to match the client’s requested version exactly — the client decides what to do with a mismatch).

4. Session header ordering

Less common and harder to self-diagnose: some server implementations mishandle session-related headers across the request sequence (initializenotifications/initializedtools/list).

Self-test: compare the session headers your server expects and returns across that sequence.

There’s no one-line fix here — if the first three causes above don’t match what you’re seeing, contact us with your server’s identifier and we’ll look at the specific run.

5. It might not be your server at all

This is the one case on this list where the fix isn’t something you need to make.

A fully spec-compliant legacy server (one built for the 2025-06-18 protocol revision or earlier) has no idea what the current revision’s server/discover handshake even is — it’s a newer method your server was never asked to implement. When a legacy server gets a request for a method it doesn’t recognize, it’s expected to fall back to responding with some 4xx error, and a checker is supposed to treat any 4xx there as “this is a legacy server, fall back to the old initialize flow” — not just one specific status code.

We had a probe version that got this narrower than it should have: it only treated an exact HTTP 400 as that fallback signal. On droproom/mcp — a fully compliant legacy implementation whose fallback response for an unrecognized method happened to be 401 with a body of {"error":"unauthorized"} (not JSON-RPC shaped, since it never got that far) — our probe didn’t recognize the fallback condition and marked discovery_handshake, protocol_revision, and tools_list as failed, with five more checks skipped as a result. The official MCP SDK client, talking to the exact same server, completed the full initializenotifications/initializedtools/list sequence without any trouble, and every other check on that same run passed.

We fixed this on 2026-08-30: the fallback condition now accepts any status in the 400499 range, matching what the specification’s own compatibility guidance for a legacy server actually says.

Self-test: run the server/discover request against your endpoint and look at both the status code and the response body. The fallback case is a status in the 400–499 range and a body that is not a JSON-RPC error with code -32020, -32021, or -32022 — those three codes mean the modern handshake reached your server and your server deliberately rejected the request (a real client/server mismatch to fix on its own terms, not a fallback situation). If your fallback response matches that — any 4xx with a non-modern-error body — and you’re still seeing those three checks fail after 2026-08-30, that’s not your problem: let us know; it would mean our probe regressed.


None of these five match what you’re seeing? Contact us with your server’s identifier and we’ll take a look at the specific run.