How to test an MCP server before you deploy it
What a client does on connect, the order mcptest checks it in, how to read the release decision, and how to run the same gate in CI.
On this page
A server that answers curl is not ready to ship. Real clients send a fixed sequence of requests, and each step can fail for a reason that a hand-written request never exercises: a preflight that rejects one header, an Accept check that is stricter than the specification, a session id the browser cannot read. This guide walks through that sequence, then shows the order mcptest checks it in, how to read the result, and how to run the same evaluation as a CI gate. For the manual request-by-request checks, keep the testing guide open alongside this one.
What a client does when it connects
The order matters because a failure early in the sequence hides everything after it. A server that rejects the first discovery request never gets its tools listed, so fix problems top-down.
- Resolve the endpoint. The client uses the exact URL it was given, for example
https://mcp.example.com/mcp. Clients that support the deprecated HTTP+SSE transport may also try a sibling/ssepath. - Preflight (browsers only). A browser sends
OPTIONSbefore the firstPOST, becauseContent-Type: application/jsonand theMCP-Protocol-Version,Mcp-Method, andMcp-Nameheaders are not on the CORS safelist. If the preflight fails, the browser never sends the request and the page sees only a generic network error. - Pick a protocol era. A client that supports
2026-07-28sendsserver/discoverwithMCP-Protocol-Version: 2026-07-28andMcp-Method: server/discover. If the server answers with a recognized modern result or error, the client stays stateless. If it answers400without a modern JSON-RPC error body, the client falls back to the 2025 handshake:initialize, thennotifications/initialized, carrying anyMcp-Session-Idthe server issued on every later request. - Authorize when challenged. A
401withWWW-Authenticate: Bearer resource_metadata="..."sends the client to the Protected Resource Metadata document, then to the authorization server's metadata, then through OAuth with PKCE. Without aresource_metadataparameter, the client probes/.well-known/oauth-protected-resource/<path>and then/.well-known/oauth-protected-resource. - Discover capabilities.
tools/list,resources/list,resources/templates/list, andprompts/list, each followed throughnextCursoruntil the last page. - Open optional streams. A 2025 client may
GETthe endpoint for a server-to-client event stream;405means none is offered. A 2026 client opens change notifications with asubscriptions/listenPOSTinstead, because the 2026 revision removed theGETstream. - Call tools. Each
tools/callcarriesMcp-Name: <tool>on 2026 requests. When the tool declares anoutputSchema, clients validatestructuredContentagainst it before using the result.
| Step | Typical failure | How it shows up |
|---|---|---|
| Preflight | Mcp-Method or Authorization missing from Access-Control-Allow-Headers | Browser network error; curl works |
| Era selection | Accept check rejects a valid header | HTTP 406 on the first POST |
| Era selection | Body parser only accepts another media type | HTTP 415 |
| 2025 handshake | Mcp-Session-Id issued but not in Access-Control-Expose-Headers | initialize succeeds, the next request is rejected |
| Authorization | Metadata document only at one well-known path | Client stops at a 404 |
| Optional stream | GET answered with HTML or text/plain | SSE content-type mismatch |
| Tool calls | structuredContent drifts from outputSchema | Client rejects the result |
What mcptest checks, and in what order
The same evaluator runs in the browser report and in the headless CLI. It follows the client sequence above and records each stage as a scored section.
Connection and era negotiation
mcptest first tries a direct connection with automatic 2026 and 2025 negotiation, starting at the exact URL and moving to conventional sibling paths only when needed. In the browser, if the direct attempt fails in a way that looks like a CORS block, it retries through the mcptest proxy and records that the proxy was required. If the target answers 401 or 403 before negotiation completes, the run stops with an authorization-required outcome instead of a score. That is a prerequisite, not a failing grade.
Scored sections
| Section | Points | What earns them |
|---|---|---|
| Core Protocol Adherence | 15 | A complete 2026 server/discover lifecycle or 2025 initialize handshake, with responses that pass the official SDK's schema validation |
| MCP Capabilities | 10 | tools/list (4), resources/list (3), and prompts/list (3) answering, with pagination followed; resources/templates/list is recorded but not scored |
| Transport Layer Modernity | 15 | Streamable HTTP earns 15; the deprecated HTTP+SSE transport earns 6 |
| Security Posture | 40 | Only when OAuth metadata exists: authorization-server metadata (20), a token endpoint (10), and PKCE S256 (10) |
| Web Client Accessibility | 15 | Browser only: 15 when direct browser negotiation worked, 0 when the proxy was required |
| Performance Baseline | 15 | Endpoint selection plus negotiation under 500 ms (15), under 1 s (12), under 2.5 s (8), otherwise 4 |
A server without OAuth is scored out of the remaining sections; Security Posture is excluded rather than counted as zero. The headless CLI leaves Web Client Accessibility out, so a public server there is scored out of 55.
Evidence that changes the decision but not the score
Two kinds of evidence are attached to the report without adding points:
- Transport diagnostics. Stable ids such as
transport.accept-header-406,transport.unsupported-media-type-415,transport.get-stream-405,transport.sse-content-type-mismatch,transport.cors-expose-headers-missing, andtransport.target-rate-limited-429. Each lists the requests that prove it and a fix for the TypeScript SDK, the Python SDK, and Cloudflare Workers. A diagnostic marked suspected is shown as a possibility and never blocks a release. - Tool surface and output contracts. The discovered tools are analyzed for unconstrained inputs, oversized descriptions, and similar risks. Tool calls you make in the playground are checked against each tool's
outputSchema, and a violation is reported astool.output-contract-violation. The headless CLI does not call tools, so output contracts only appear in reports generated after playground calls.
How to read the release decision
The top of every report answers one question: can this ship? The answer combines the protocol result, host compatibility profiles for ChatGPT, Claude, Cursor, VS Code Copilot, and a generic SDK client, transport diagnostics, tool-surface findings, and output contracts.
| Status | Meaning | What to do |
|---|---|---|
ready | No priorities at all | Ship |
review | No blocker, but medium findings or unknown observations remain | Read each priority and decide; many are host behavior a single run cannot establish |
blocked | Negotiation failed, or at least one high or critical priority exists | Fix the first priority and rerun |
unknown | A required check was skipped, so the report is partial | Resolve the skipped check, then rerun |
authorization-required | The target challenged before it could be assessed | Supply a credential or complete OAuth, then rerun |
Priorities are sorted critical, high, medium, then unknown. Each has a stable id, a source (Evaluation, Host compatibility, or Tool surface), the evidence, and a remediation. Detected transport errors such as a 406 become high priorities and block release; warnings such as a refused optional GET stream become medium priorities and only ask for review.
A first report against a small stateless server often reads review, with an unconstrained-inputs finding and several unknown host-compatibility observations, such as session behavior the evaluation could not establish. That is a normal starting point, not a failure. Constrain the inputs, then decide whether the remaining unknowns matter for the hosts you target.
Run the same checks in CI
The headless command uses the same evaluator, profiles, and release-decision logic as the web report. It needs Node.js 22 and a checkout of the mcptest repository with dependencies installed:
npm ci
npm run mcptest -- https://staging.example.com/mcpEach endpoint produces a JSON report that conforms to https://mcptest.io/schemas/report/v2.schema.json and a Markdown report, in mcptest-reports/ by default. Credentials are passed by environment variable name, never as literal arguments:
export MCPTEST_BEARER_TOKEN='staging-token'
npm run mcptest -- --bearer-token-env MCPTEST_BEARER_TOKEN https://staging.example.com/mcpThe default gate fails on blocked or unknown and on any high or critical priority; review passes. A stricter gate that also fails on review decisions and medium findings looks like this:
npm run mcptest -- \
--fail-on-result blocked,unknown,review \
--fail-on-severity medium \
https://staging.example.com/mcp| Exit code | Meaning |
|---|---|
| 0 | Every endpoint passed the configured thresholds |
| 1 | At least one completed evaluation failed a threshold |
| 2 | At least one endpoint requires interactive or browser authorization |
| 3 | Arguments, URLs, environment references, or thresholds are invalid |
| 4 | The evaluator threw unexpectedly or artifacts could not be written |
Run the gate against a staging deployment after it starts and before you promote it. Upload the reports with if: always() so a failed gate stays inspectable. The repository also ships a reusable workflow, .github/workflows/mcptest-release-gate.yml, that takes newline-separated endpoints and a credential mode; the release gate documentation (opens in a new tab) has both the minimal job and the reusable-workflow call.
What the headless run cannot tell you
A Node process is not a browser, so the CLI never claims browser access: CORS evidence is reported as unknown and is not added to the headless score. It also does not open a browser for OAuth; a target that challenges without an accepted bearer token or API key produces an authorization-required report and exit code 2. Run the browser report once per release candidate for those two areas, and see CORS headers for browser clients for what that report expects.
Add the gate to CI
Put npm run mcptest -- --endpoints-env MCPTEST_ENDPOINTS in the deploy pipeline between "staging is up" and "promote to production", with the endpoint list in MCPTEST_ENDPOINTS and the report directory uploaded as an artifact. Start with the default thresholds, and tighten to --fail-on-result blocked,unknown,review once the first reports are clean.