How to Test an MCP Server: Contracts, Failures, and CI
What is MCP server testing?
MCP server testing verifies a server's tool contracts, protocol behavior, transport, authorization, failure handling, and real agent workflows. It combines unit tests, client-driven contract tests, official conformance checks, and end-to-end task evaluations.
TL;DR
- -MCP Inspector is excellent for exploration and debugging, but a production server also needs repeatable contract, transport, authorization, failure, and agent-level tests
- -Treat tools/list as a public API: verify names, descriptions, input and output schemas, annotations, deterministic order, and changes in CI
- -Run each claimed protocol revision at its own wire behavior; 2025-11-25 and 2026-07-28 do not share the same lifecycle or cancellation path
- -Test deadlines, cancellation, retries, duplicate writes, partial upstream failures, malformed results, and process shutdown—not only valid tool calls
- -Conformance proves protocol behavior; scenario evaluation proves that an agent selects the right tool and completes the user's task
An MCP server can pass a demo and still fail the first real workflow. The tool appears
in tools/list, one happy-path call returns JSON, and the Inspector looks green. None
of that proves what happens when a token has the wrong audience, an upstream hangs, a
client retries a write, or an agent chooses the wrong tool.
Test the server as three things at once:
- a normal application with business logic;
- a versioned protocol implementation;
- a capability surface used by a probabilistic caller.
This guide assumes you already know the MCP architecture. For deployment and recovery patterns, use the production MCP server guide; for access control and adversarial cases, keep the MCP security guide beside this test plan.
Use a test stack, not one tool
Each layer catches a different class of failure:
| Layer | Runs against | Best at finding |
|---|---|---|
| Handler unit test | Tool function and fake dependencies | Validation, policy, mapping, business rules |
| Contract test | Real MCP client and server | Advertised schemas, error shapes, serialization |
| Transport test | Spawned stdio process or HTTP endpoint | Framing, auth, cancellation, lifecycle, cleanup |
| Conformance test | Official harness | Violations of a dated MCP specification |
| Agent scenario | Real host/model with controlled fixtures | Tool selection, sequencing, recovery, task completion |
Do not replace the first four with model evaluation. Deterministic bugs deserve deterministic tests. Conversely, a unit test cannot tell you that two plausible tool descriptions cause the model to select the destructive one.
Start with a contract inventory
Before writing cases, list every exposed surface:
- tools, resources, prompts, and advertised capabilities;
- supported transports and protocol revisions;
- authentication modes and scopes;
- external APIs, databases, filesystems, queues, and secret stores;
- state handles, subscriptions, or multi-round-trip input;
- limits: request size, result size, concurrency, rate, deadline, and retention.
Turn that list into a small matrix. A server with two transports, two protocol eras, and three identity roles already has twelve meaningful boundary combinations. You do not need every business case in every combination; you do need one smoke path and the version-specific behavior for each claimed combination.
Treat tools/list as a public API
Tool metadata is not decoration. It is both a machine-readable contract and part of the model’s routing context. A casual description edit can change which tool gets called even when the handler code is untouched.
For every release, verify:
- tool names and deterministic order;
- descriptions that state action, constraints, and side effects;
- required versus optional arguments;
additionalPropertiesbehavior;- formats, enums, bounds, and maximum lengths;
- input schema dialect and valid
$refresolution; - output schema and structured result shape;
- annotations, while remembering they are untrusted hints;
- the tool set returned for each authorization level.
Normalize the response before snapshotting it. Remove timestamps, generated IDs, and other volatile metadata. Keep a human review on semantic changes:
{
"name": "close_issue",
"description": "Close one issue after explicit user confirmation.",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"issue_id": { "type": "string", "pattern": "^iss_[a-z0-9]+$" },
"reason": { "type": "string", "minLength": 3, "maxLength": 500 }
},
"required": ["issue_id", "reason"]
}
}
Then test that the implementation enforces the same contract. A beautiful schema with a handler that accepts extra fields is documentation, not validation.
The current tools specification requires valid JSON Schema, separates protocol errors from tool execution errors, and asks clients to validate results before passing them to an LLM.
Test handlers without the protocol first
A tool handler should be callable with injected dependencies: repository, upstream client, clock, ID generator, authorization context, and audit sink. That makes the fastest tests ordinary application tests.
For a write tool, cover at least:
valid request
missing or malformed field
unknown field
object not found
object belongs to another tenant
caller lacks permission
invalid state transition
upstream timeout
upstream rate limit
duplicate idempotency key
audit sink unavailable
Assert side effects, not only returned text. Did the repository receive exactly one write? Was the tenant derived from verified identity rather than a model argument? Was the upstream request aborted? Did a failed audit write block a high-risk mutation or degrade safely according to policy?
Use seeded clocks and IDs. Avoid sleeping in unit tests; a fake clock can cross a deadline or expiry instantly.
Verify protocol errors and tool errors separately
Protocol errors cover malformed JSON-RPC, an unknown method, or a request that does not match the protocol shape. They are returned as JSON-RPC errors.
Tool execution errors cover invalid business input, an API failure, or a forbidden
state transition the model may be able to correct. They return a tool result with
isError: true and useful, bounded feedback.
Test both. Also test that errors do not leak stack traces, SQL, internal URLs, tokens, or full upstream bodies. A useful error tells the caller what can be changed without exposing the system behind it. Do not turn every failure into successful text such as “Something went wrong.” Hosts lose the signal they need for retries and recovery.
Run the real transport
In-process tests miss the boundary where many MCP bugs live.
stdio
Spawn the built artifact as a child process, then assert:
- stdout contains only framed MCP messages and diagnostics go to stderr;
- split and back-to-back messages are parsed correctly;
- malformed input does not corrupt the next request;
- closing stdin ends the process within the shutdown budget;
- SIGTERM cancels or drains in-flight work according to policy;
- the process does not inherit unrelated secrets.
Run at least one test from a directory containing spaces and with a minimal environment. That catches path and implicit-shell assumptions hidden by a developer machine.
Streamable HTTP
Exercise the deployed HTTP handler, not only the tool function:
- method and content-type handling;
- authorization on every request;
- token issuer, audience, expiry, and scope failures;
- origin and host validation where the deployment requires it;
- body and response size limits;
- concurrent calls from different principals;
- request-scoped SSE response and disconnect cleanup;
- reverse-proxy timeouts, forwarded metadata, and CORS.
The current transport model sends each HTTP message as a POST and may return either a JSON object or a request-scoped SSE stream. That stream is also part of cancellation, so a proxy buffering or swallowing disconnects can change server behavior.
Test every protocol era you claim
This became non-negotiable with the July 2026 revision. The two current behavior families differ materially:
| Behavior | Through 2025-11-25 | 2026-07-28 |
|---|---|---|
| Connection start | initialize handshake | server/discover, no initialize |
| Client metadata | Session-scoped | _meta on every request |
| Streamable HTTP cancellation | notifications/cancelled | Close the request stream |
| Change events | Unsolicited notifications | subscriptions/listen |
Liveness ping | Defined | Not defined |
The official TypeScript SDK calls these the legacy and modern eras in its protocol version guide. Do not run a test with one wire version and label the server compatible with both.
For each supported revision, verify negotiation, required metadata, cancellation, errors, and version-specific features. Also test the refusal path: a client that pins an unsupported revision should get a clear failure rather than silent partial behavior.
Make timeouts and cancellation observable
Create a fake upstream that can behave on command:
respond immediately
delay before headers
stream one chunk, then hang
return malformed JSON
close mid-response
ignore cancellation
return 429, 500, or 503
For each slow path, assert four things:
- the caller gets a bounded result before its deadline;
- cancellation reaches the handler through the correct transport/version mechanism;
- the upstream request and acquired resources are released;
- telemetry distinguishes timeout, cancellation, and upstream failure.
A progress notification must not extend a hard deadline forever. Set separate budgets for queueing, upstream work, serialization, and total task time. Test the boundary values, not just a comfortably short and comfortably long case.
Retries require an idempotency decision
Read operations are often safe to retry. Writes are not automatically safe because the transport disconnected before the response arrived.
For every mutating tool, choose one policy:
- accept an idempotency key and return the original result on replay;
- expose a read-back operation so the caller can reconcile state;
- make the operation naturally idempotent;
- mark it non-retryable and require a fresh confirmation.
Test a write that commits upstream and loses the response. Then replay the exact call. The expected outcome must be explicit: one object, one charge, one message, or a clear conflict—not “usually one.”
Use Inspector for discovery and CI smoke tests
The MCP Inspector remains the quickest way to inspect schemas and reproduce a failure. The current tool offers web, CLI, and terminal interfaces. Use the web UI while developing and the CLI for a small deployment smoke test:
npx @modelcontextprotocol/inspector --cli \
node build/server.js \
--method tools/list \
--format json
npx @modelcontextprotocol/inspector --cli \
--server-url https://mcp.example.com/mcp \
--transport http \
--method tools/call \
--tool-name health \
--format json
Pin the Inspector version in CI and keep secrets in the CI secret store. Do not expose the Inspector proxy to an untrusted network: it can launch local processes and connect to server targets.
Add official conformance checks
The official MCP conformance framework drives a running server, captures protocol traffic, and validates messages against the wire schema. For an HTTP server:
npx @modelcontextprotocol/conformance server \
--url http://127.0.0.1:3000/mcp \
--requirements 2026-07-28
Use the frozen --requirements set for the revision you claim. A rolling suite can
gain scenarios after that revision shipped; it answers a different question. If you
support both eras, run both requirement sets at their actual wire versions.
Conformance is necessary protocol evidence, not a product certification. It does not
know that close_issue skipped tenant authorization or that its description causes an
agent to pick it for “archive this note.”
Finish with agent scenarios
Build a small fixed dataset of user tasks and fixtures. Include:
- a task with one obvious tool;
- two similar tools where only one is permitted;
- missing information that should trigger clarification;
- a recoverable tool error;
- an irreversible action that must pause for approval;
- untrusted content containing an instruction to call another tool;
- an upstream outage where the agent should stop rather than loop;
- a request the server and agent must refuse.
Score the final task state and the tool trajectory: tools called, order, arguments, approvals, retries, and stop condition. Do not require exact prose or hidden chain of thought. The AI agent testing guide covers dataset versioning, judge calibration, and release gates.
A practical CI gate
Every pull request
- schema and normalized contract diff;
- handler and in-process protocol tests;
- one stdio or HTTP transport smoke test;
- secret scan over logs and fixtures.
Before release
- all supported protocol versions;
- authorization and tenant-isolation suite;
- failure injection, cancellation, shutdown, and duplicate-write cases;
- frozen conformance requirements;
- fixed agent regression dataset.
After deployment
- Inspector CLI health and read-only canary;
- metrics for error class, latency, cancellation, retry, and result size;
- rollback check with a known compatible client.
Keep the gate boring. A server is ready when the same failures are caught repeatedly, not when someone can complete a perfect demo in the Inspector.