Why does my MCP server show "observed-unsigned"?
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.
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.
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.
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.
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
(initialize → notifications/initialized → tools/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 initialize → notifications/initialized → tools/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 400–499 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.