How to test MCP servers: from local dev to production
Testing an MCP server properly means proving five separate things: that it runs, that it speaks a current protocol revision correctly, that its tool schemas make sense to a model, that its transport and auth behave under hostile input, and that an outside client actually sees what you think it sees. Most guides stop after the first one.
We test the other four for a living. VerifyMCP operates an automated harness that connects to every server in the official MCP registry, negotiates the protocol, reads the tool schemas and probes transport and auth. This post walks through the same progression a maintainer should follow, from a local run to outside verification, including the failure modes our harness sees most.
One thing has changed recently that most testing advice has not caught up with: the current spec revision, 2026-07-28, removed the initialize handshake and made the protocol stateless. Every request is now self-contained, which changes what a smoke test looks like. More on that below.
What does testing an MCP server involve?
| Stage | What you are proving | Typical tooling |
|---|---|---|
| Local run | The server starts and each tool executes | MCP Inspector, SDK unit tests |
| Protocol smoke test | It answers a spec-correct request on the wire | curl + server/discover |
| Schema review | A model can select and call the tools | tools/list output, schema validation |
| Auth and transport | Challenges, Origin checks and TLS behave | curl with and without credentials |
| Outside verification | A stranger’s client observes what you intend | An independent scan of your endpoint |
Each stage catches a different class of bug, and passing an earlier stage says nothing about a later one. A server can work flawlessly in the Inspector while rejecting every spec-conformant request from a production client, and a server can be protocol-perfect while shipping tool descriptions no model can act on.
What should the manual pass in the Inspector actually test?
Stage one is interactive work in the MCP Inspector. We cover the commands, the three clients, the environment variables and the two CVEs filed against it in MCP Inspector: debug and verify an MCP server. Clicking Connect proves only that the process started. A session earns its time when it does three things:
- List everything. Confirm every tool, resource and prompt you expect is present, and nothing you do not expect.
- Call each tool with valid input, then with invalid input, missing required parameters, and oversized values. The official guide recommends exactly this edge-case pass: invalid inputs, missing prompt arguments, concurrent operations.
- Watch the notifications pane for server logs while you do it. Silent failures usually show up there first.
That is exploratory work, and it exercises your server through one client’s behaviour on one afternoon. It does not survive contact with a refactor. Every later stage exists because you cannot repeat a manual pass cheaply. That is the real argument for running the Inspector’s headless --cli mode in CI, and for unit-testing your tool handlers directly in your SDK’s test framework, asserting on structured results rather than on whatever a UI happened to render.
How do you smoke-test the protocol itself?
This is where the 2026-07-28 revision matters. Under earlier revisions, the smoke test was an initialize round trip. That handshake is gone. The protocol is now stateless: every request carries its own protocol version and client capabilities in _meta, servers must not infer anything from earlier requests on the same connection, and the Mcp-Session-Id header no longer exists in the Streamable HTTP transport.
In place of the handshake, every server must implement server/discover, which reports supported protocol versions, capabilities and identity in one response. That makes it the natural smoke test. For a remote server, the whole check is one curl:
curl -s https://mcp.example.com/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/clientCapabilities": {}
}
}
}'
Every part of that request is load-bearing. The Accept header must list both application/json and text/event-stream. The MCP-Protocol-Version and Mcp-Method headers are required, and the version header must match the _meta field in the body; a mismatch earns a 400 with JSON-RPC error -32020 (HeaderMismatch). The _meta block must carry the protocol version and client capabilities on every request, because there is no session to remember them.
Three outcomes are possible, and each tells you something:
- A
DiscoverResultwithsupportedVersions,capabilitiesand (usually)serverInfo. The server is modern and alive. Check that the versions listed are the ones you intended to ship. - A
400carrying error-32022(UnsupportedProtocolVersionError) with adata.supportedlist. The server is modern but does not speak the version you asked for. That is a working server telling you how to talk to it; per the versioning rules, a client should retry with a mutually supported version. - A
4xxwith no recognisable modern error body. The server predates this revision and expects aninitializehandshake.
For stdio servers the framing is newline-delimited JSON over the process’s standard streams, and the spec’s own guidance for clients that must handle both eras is to probe with server/discover first and fall back to initialize on any error that is not a recognised modern one. That probe order is exactly what our harness runs against every remote endpoint in the registry, and it is worth wiring into your own integration tests: assert that server/discover returns the versions you expect, and that an unsupported version gets a clean -32022 rather than a hang or a stack trace.
How do you test tool schemas as a model reads them?
The output of tools/list is your real user interface. A model chooses tools by reading names, descriptions and input schemas, so a schema that validates perfectly can still fail the only test that matters: whether a model can decide when to call the tool and what to pass it.
Our scoring engine measures this deterministically across the registry, and the checks translate directly into things you can test yourself:
- Every tool has a description that says something. We compute the fraction of tools carrying a non-trivial description, because a bare
description: "Runs the query"passes every validator and informs nobody. Read each of yours and ask whether a stranger could tell when not to call it. - Parameters are documented. A parameter named
idwith no description forces the model to guess which id, in what format. - The schema dialect is what you think it is. Schemas without a
$schemafield default to JSON Schema 2020-12 under the current spec, and implementations must validate against the declared or default dialect. If you authored draft-07 schemas, say so explicitly or test against 2020-12 semantics. - No network
$refs. Implementations must not automatically dereference a$refthat points at a network URI, and schemas that fail to resolve should be rejected rather than treated as permissive. A remote$refthat works in your validator may make your tool unusable in a conforming client. - One bad
x-mcp-headerannotation removes the whole tool. If a tool schema mirrors parameters into HTTP headers, the constraints are strict, and a conforming HTTP client must exclude the entire tool fromtools/listif any annotation is invalid. Your server will look healthy while a tool silently vanishes from clients.
A quick manual version of this test: pipe your tools/list response into a file, hand it to a model with a realistic task, and see which tool it picks and what arguments it fabricates. Ambiguity you have stopped noticing shows up immediately.
How do you test auth and transport?
Auth on remote MCP servers follows the spec’s authorization framework, which is OAuth 2.1 plus a discovery layer. Three probes cover most of it:
Send a request with no token. A protected server must respond 401 Unauthorized with a WWW-Authenticate header whose resource_metadata parameter points at its RFC 9728 protected resource metadata, served under /.well-known/oauth-protected-resource. Fetch that document and confirm it names your authorization server. If the challenge is missing, conforming clients cannot discover how to authenticate at all. Our remote authentication guide walks through setting this up correctly.
Send a bogus Origin header. Servers must validate Origin on incoming connections to prevent DNS rebinding, and respond 403 Forbidden when it is present and invalid. Locally running servers should also bind to 127.0.0.1, not 0.0.0.0; a development server listening on all interfaces is reachable by any site your browser visits.
Check where tokens travel. Access tokens belong in the Authorization: Bearer header on every request and must never appear in the URI query string. While you are at it, confirm the endpoint serves TLS 1.2+ with a certificate chain that validates, because that is the first thing any scanner, ours included, will look at.
What actually breaks when servers are tested at scale?
Running these checks against one server is a morning’s work. Running them against the whole registry is how you learn which failures are common rather than theoretical. As of 18 August 2026, the official registry listed 23,066 servers with 25,124 components, and our harness attempts to connect to all of them. What recurs:
- Servers that never respond. A registry entry is a claim. Some claimed endpoints hang, refuse connections or no longer exist, which is why we probe continuously rather than trusting metadata.
- Servers that cannot be observed from outside. 1,487 of 12,682 remote components declared a required secret header in their registry entry as of 18 August 2026, and the true auth-gated population is larger, because a server can demand OAuth without declaring one. For those, no external tester can read capabilities or schemas at all. That is a legitimate design choice, and we record the affected signals as inconclusive rather than pretending to knowledge we do not have.
- Versions you cannot confirm. We observed a declared protocol version for 13,439 of 25,124 components; the remainder were unreachable, auth-gated or not probeable. And where we can observe behaviour, the version a registry entry declares is not always the era the endpoint actually speaks, which is exactly the bug the
400-body rule above exists to catch. - Schemas that are valid and useless. Malformed JSON gets caught early in any pipeline. The harder failure is a schema that validates cleanly while telling a model nothing, which is why our tool coverage checks measure description coverage as a fraction of the captured schema rather than a pass/fail parse.
The last test: look at your server from outside
Everything above tests your server from your side of the fence. The final step is to see it the way an unknown client does, with none of your context. We publish exactly that view: every server in the official registry has a page showing what our harness could connect to, which protocol versions it observed, what the tool schemas look like from outside and how transport and auth held up.
Look up your server in the registry and compare what we observed against what you intended to ship. If a signal surprises you, how we score documents every check and what moves it. A gap between the two is the most useful test failure you will find, because it is the one your users are already experiencing.