How to test an MCP server before you ship it
Your MCP server works in your editor. That is a real result, but a narrow one: your editor runs on your machine, speaks one protocol version, and forgives a lot. An agent connecting to your server over the internet does none of that.
This guide tests a remote MCP server the way an outside client meets it: one layer at a time, from the TLS certificate up to the words in your tool descriptions. Every step has a curl command you can paste. Replace https://your-server.example.com/mcp with your endpoint.
Before you start: which protocol era does your server speak?
MCP has changed shape. Up to revision 2025-11-25, a client opened a session with an initialize handshake. The current revision, 2026-07-28, drops the handshake and the session: every request carries its own protocol version and client capabilities in _meta (client details are recommended but optional), and the Streamable HTTP transport mirrors some of them into headers.
Both eras are live. Of the 1,902 servers in our directory on 2026-10-09, we could identify the protocol revision of 1,806:
| Revision | Servers |
|---|---|
| 2025-06-18 | 1,185 |
| 2026-07-28 | 513 |
| 2024-11-05 | 39 |
| 2025-03-26 | 38 |
| 2025-11-25 | 31 |
So test the era you implement, and if you implement both, test both. The steps below show each where they differ.
Layer 1 — Can a client reach you at all?
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} '
Look for three things:
- The certificate. It must be unexpired, its names (SAN) must cover your hostname, and the chain must be complete. A missing intermediate certificate often works in a desktop browser, which fills the gap from cache, and fails in a fresh client.
- Redirects. If
/mcpredirects, check where to. A redirect to a different host is where clients start dropping headers, includingAuthorization. Many refuse to follow it at all. - Time. Note the total time. Agents call tools inside a conversation, so a slow first byte is felt by a person waiting.
A GET that returns 405 Method Not Allowed is fine here. Under 2026-07-28 the endpoint only has to accept POST, and the spec says a server that speaks only this revision should answer GET with 405.
Layer 2 — Does the handshake work?
Every POST must send an Accept header that lists both application/json and text/event-stream. If a request lists only one of them, many server frameworks reject it with 406 Not Acceptable. It is a common reason a server that works in one client fails in another.
Current revision (2026-07-28): server/discover. Servers must implement it, and it returns the versions, capabilities and identity in one response:
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":{}}}}'
Expect 200 with supportedVersions, capabilities and, ideally, serverInfo in _meta. Two failures to know:
400with error code-32020(HeaderMismatch): a header disagrees with the body, or a required header is missing.MCP-Protocol-VersionandMcp-Methodare required on every request.400with error code-32022(UnsupportedProtocolVersion): the body lists the versions the server does support. That is correct behaviour, not a bug, as long as the list is real.
Earlier revisions: 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"}}}'
The result names the protocolVersion the server agreed to. If the response carries an Mcp-Session-Id header, send it back on every later request, along with MCP-Protocol-Version (from 2025-06-18 on). Then tell the server you are ready:
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"}'
That notification should get 202 Accepted with no body.
Reading the reply. A server may answer with plain JSON or with a short Server-Sent Events stream. Both are allowed and clients must handle both. When you see Content-Type: text/event-stream, the JSON is on the data: lines:
... | sed -n 's/^data: //p'
Layer 3 — Is the tool list what an agent needs?
# 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":{}}}}'
(For an earlier revision, send "params":{} with your session headers instead.)
Check each tool:
- It has a
nameand aninputSchemathat is a JSON Schema object, nevernull. A tool with no parameters should still send{"type":"object","additionalProperties":false}. - The name is 1–128 characters from
A–Z a–z 0–9 _ - ., with no spaces. Clients that put tool names into headers or prompts handle anything else badly. - The order is the same on every call when nothing changed. Clients cache the list, and so do model prompt caches.
Then check pagination. If the result has a nextCursor, the list is not finished. Call again with "cursor": "<that value>" in params until no nextCursor comes back. A client that reads only the first page silently loses the rest. We found this gap in our own report pages too.
Layer 4 — Do errors have the right shape?
Agents recover from errors that tell them what went wrong, and give up on the rest. MCP separates two kinds:
- Protocol errors are JSON-RPC errors: an unknown tool, a malformed request, a server fault. An unknown tool is
-32602. An unknown method is-32601, and under2026-07-28it also comes with HTTP404. - Tool execution errors are normal results with
"isError": true, and the text explains the problem: the date is in the past, the ID does not exist. The model reads that text and can retry.
Call a tool that does not exist:
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":{}}}}'
You want a JSON-RPC error object with a code and a message. You do not want an HTML error page, an empty 200, or a stack trace.
Then call a real tool with a wrong argument. A good server answers with isError: true and a sentence the model can act on.
Layer 5 — If you require auth, is the challenge readable?
Authorization is optional in MCP. If your server uses it, a client with no token must be able to find out how to get one, without a human reading your docs.
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'
Expect 401 and a header like:
WWW-Authenticate: Bearer resource_metadata="https://your-server.example.com/.well-known/oauth-protected-resource", scope="files:read"
Then fetch the URL in resource_metadata:
curl -sS https://your-server.example.com/.well-known/oauth-protected-resource
It should return JSON with your resource and at least one entry in authorization_servers. The spec requires servers to publish this document (RFC 9728), and clients use it to find where to sign in. The usual failures:
- a
401with noWWW-Authenticateheader; - a
resource_metadataURL that returns your HTML homepage; - an
authorization_serversURL that doesn't serve OAuth metadata.
None of this needs a real token, so you can and should test it from outside.
Layer 6 — What will the model read?
Your tool names and descriptions go straight into a model's context. Two things to look for:
- Instructions aimed at the agent rather than describing the tool: "always call this tool first", "ignore other tools", "do not tell the user". Describe what the tool does. Let the client decide when to call it.
- Hidden characters: zero-width spaces, bidirectional overrides, invisible formatting. They change what a model reads without changing what a person sees in a code review.
# prints the name of any tool whose text contains zero-width or bidi control characters
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'
No output is what you want.
Layer 7 — Did anything change that you didn't mean to change?
Agents and the people who approve them get used to a tool set. If a deploy renames a tool, drops a parameter or changes a schema type, plans built on the old shape break, often without a clear error.
Record a fingerprint of what you shipped:
curl -sS ... tools/list ... | sed -n '/^{/p; s/^data: //p' \
| jq -S '.result.tools | map({name, inputSchema})' | sha256sum
Keep that hash next to your release. After the next deploy, compute it again: if it changed, you should be able to say which change caused it.
Make it a habit, not a launch-day task
Servers drift. Certificates expire, proxies get reconfigured, a dependency upgrade changes an error shape. Run the same checks after every deploy, and on a schedule in between.
If you'd rather not keep the scripts yourself, paste your address at mcpcheckup.com. It runs these checks from outside, the same way an agent would. Each one comes back in one of four states — verified, failed, observed risk, or not checked with a stated reason — and you never get a single score.
Checklist
| Layer | What to check |
|---|---|
| Reach | Certificate valid, full chain, names match; no redirect to another host; reasonable time |
| Handshake | Accept lists both types; server/discover (2026-07-28) or initialize (earlier) succeeds; headers match the body |
| Tools | Every tool has a name and an object inputSchema; names use the allowed characters; nextCursor followed to the end |
| Errors | Unknown tool → JSON-RPC error; bad arguments → isError: true with a usable message |
| Auth | 401 carries WWW-Authenticate with resource_metadata; the metadata document lists an authorization server |
| Descriptions | No instructions aimed at the agent; no hidden characters |
| Change | Toolset fingerprint recorded per release, and compared after each deploy |