How to Test an MCP Server: 3 Layers That Actually Work
Tom PiaggioCo-Founder at Autonoma
Testing an MCP server means verifying three independent layers: that the protocol handshake and tool listings are well-formed, that each tool's underlying function returns correct values and handles errors deterministically, and that a calling LLM actually selects the right tool with the right arguments given a natural-language prompt. Model Context Protocol testing also has to account for transport (stdio, HTTP, SSE) and auth, each of which fails in its own way.
Search "how to test an MCP server" today and you'll find a listicle naming three tools, a paragraph of prose describing test categories, and not one runnable command. You will not find a single assertion, a single pytest file, or a single test that accounts for the fact that an MCP server has to survive three different transports and an authorization boundary that most write-ups never get to.
That gap is what this guide closes. Every layer below is something you can run in the next twenty minutes: the MCP Inspector CLI commands, the pytest suite against the tool functions, the tool-selection eval harness, the transport tests, and the auth tests. None of it is prose describing what testing "should" look like.
Why Testing an MCP Server Is Different From Testing a Normal API
A normal API has one boundary to test: does the request produce the right response. An MCP server has that same boundary, but it sits behind a second one that didn't exist in traditional API testing at all. Before your tool's code ever runs, an LLM has to read a natural-language request, decide which of your exposed tools applies, and construct arguments for it. Get the response contract right and the tool selection wrong, and the user still gets a broken experience, even though every individual function returned exactly what it was supposed to.
That second boundary is genuinely new, and it's genuinely probabilistic. You cannot unit test "did the model pick the right tool" the way you unit test "did the discount function apply the right percentage," because the same prompt, run twice, can produce two different tool calls. Treating it like a normal deterministic test (one run, exact match, pass or fail) produces a suite that's either flaky for no diagnosable reason or so loose it never catches a real regression.
The fix is to stop treating MCP testing as one layer and start treating it as three, each with a different failure mode and a different testing technique.
Each layer needs a different testing technique, because each layer fails in a different way. Running all three the same way is how MCP test suites end up either flaky or useless.
Layer
What It Tests
Tooling
Failure Signal
Protocol & handshake
Message shape, tool listing
MCP Inspector CLI
Malformed JSON-RPC, missing fields
Deterministic units
Tool function logic
pytest
Wrong return value, unhandled edge case
Tool selection
LLM picks tool + args
Eval harness, N runs
Wrong tool, wrong arguments, flakiness
Layer 1: Protocol and Handshake Tests With the MCP Inspector CLI
The first layer doesn't touch an LLM at all. It asks a narrower question: does your server speak MCP correctly. Can a client complete the initialize handshake, list your tools, call one, and get back a well-formed JSON-RPC response with the fields a client actually needs.
Here's a minimal MCP server exposing two tools, a weather lookup and a ticket-creation tool, that the rest of this guide tests against:
"""Minimal two-tool MCP server used as the companion code for testing anMCP server end-to-end.Transports: stdio (default): the standard MCP stdio transport, driven by `mcp dev src/server.py` or by an MCP client/inspector spawning this file as a subprocess. This is the path exercised by scripts/inspector_checks.sh and tests/test_transport_stdio.py. http: a small stdlib-only HTTP + JSON-RPC-shaped transport with bearer-token (JWT) auth and scope-based tool visibility, used to demonstrate testing an MCP server's auth boundary (see tests/test_transport_http.py and tests/test_auth.py). This is not part of the official MCP SDK's transports; it is hand-rolled here purely for the auth/transport testing exercise in the companion blog post. sse: a companion stdlib-only Server-Sent Events transport demonstrating incremental delivery and Last-Event-ID resumption (see tests/test_transport_sse.py).Usage: python src/server.py # stdio (default) mcp dev src/server.py # stdio via MCP Inspector dev UI python src/server.py --transport http --port 8787 python src/server.py --transport sse --port 8787"""from __future__ import annotationsimport argparseimport jsonimport sysimport timefrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerfrom typing import Any, Dict, FrozenSet, Listtry: from .tools import create_ticket as _create_ticket, get_weather as _get_weather from .auth import AuthError, decode_bearer_token, require_scopeexcept ImportError: # running as a top-level script: `python src/server.py` import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from tools import create_ticket as _create_ticket, get_weather as _get_weather from auth import AuthError, decode_bearer_token, require_scopefrom mcp.server.fastmcp import FastMCPmcp = FastMCP("mcp-testing-demo")@mcp.tool()def get_weather(city: str) -> dict: """Get current weather conditions for a supported city. Args: city: City name, e.g. "Austin" or "Tokyo". """ return _get_weather(city)@mcp.tool()def create_ticket(title: str, description: str = "") -> dict: """Create a support ticket and return its id, title, and status. Args: title: Short ticket title. Required, non-empty. description: Optional longer description of the issue. """ return _create_ticket(title, description)# ---------------------------------------------------------------------------# Tool registry shared by the custom HTTP/SSE transports below. Kept separate# from the FastMCP decorators above so the auth-aware transports can# list/invoke tools without going through the stdio protocol.# ---------------------------------------------------------------------------_TOOL_SCHEMAS: Dict[str, Dict[str, Any]] = { "get_weather": { "name": "get_weather", "description": "Get current weather conditions for a supported city.", "inputSchema": { "type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], }, }, "create_ticket": { "name": "create_ticket", "description": "Create a support ticket and return its id, title, and status.", "inputSchema": { "type": "object", "properties": { "title": {"type": "string"}, "description": {"type": "string"}, }, "required": ["title"], }, },}_TOOL_FUNCS = { "get_weather": lambda args: _get_weather(args["city"]), "create_ticket": lambda args: _create_ticket( args["title"], args.get("description", "") ),}def _call_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: if name not in _TOOL_FUNCS: raise ValueError(f"unknown tool: {name}") result = _TOOL_FUNCS[name](arguments) return {"content": [{"type": "text", "text": json.dumps(result)}]}def _tools_list_for_scope(granted: FrozenSet[str]) -> List[Dict[str, Any]]: return [schema for tname, schema in _TOOL_SCHEMAS.items() if tname in granted]# ---------------------------------------------------------------------------# HTTP transport: single JSON-RPC-shaped endpoint, bearer-token + scope auth.# ---------------------------------------------------------------------------class _HTTPHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib hook, quiet by default pass def _send_json(self, status: int, payload: Dict[str, Any]) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_POST(self) -> None: # noqa: N802 - stdlib naming convention content_length = int(self.headers.get("Content-Length", 0) or 0) raw_body = self.rfile.read(content_length) if content_length else b"{}" try: granted = decode_bearer_token(self.headers.get("Authorization")) except AuthError as exc: self._send_json(exc.status, {"error": str(exc)}) return try: request = json.loads(raw_body or b"{}") except json.JSONDecodeError: self._send_json(400, {"error": "invalid JSON body"}) return method = request.get("method") req_id = request.get("id") params = request.get("params") or {} if method == "tools/list": self._send_json( 200, { "jsonrpc": "2.0", "id": req_id, "result": {"tools": _tools_list_for_scope(granted)}, }, ) return if method == "tools/call": name = params.get("name") try: require_scope(granted, name) except AuthError as exc: self._send_json(exc.status, {"error": str(exc)}) return try: result = _call_tool(name, params.get("arguments") or {}) except ValueError as exc: self._send_json(400, {"error": str(exc)}) return self._send_json(200, {"jsonrpc": "2.0", "id": req_id, "result": result}) return self._send_json(400, {"error": f"unsupported method: {method}"})def _run_http(port: int) -> None: server = ThreadingHTTPServer(("127.0.0.1", port), _HTTPHandler) print(f"MCP HTTP transport listening on http://127.0.0.1:{port}", file=sys.stderr) server.serve_forever()# ---------------------------------------------------------------------------# SSE transport: deterministic 5-event stream with Last-Event-ID resumption.# ---------------------------------------------------------------------------_SSE_EVENTS: List[Dict[str, Any]] = [ { "id": i, "event": "tool_status", "data": {"seq": i, "tool": "get_weather", "status": "ok"}, } for i in range(1, 6)]class _SSEHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def log_message(self, format: str, *args: Any) -> None: # noqa: A002 pass def do_GET(self) -> None: # noqa: N802 try: decode_bearer_token(self.headers.get("Authorization")) except AuthError as exc: body = json.dumps({"error": str(exc)}).encode("utf-8") self.send_response(exc.status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) return last_event_id_raw = self.headers.get("Last-Event-ID") last_event_id = int(last_event_id_raw) if last_event_id_raw else 0 # No Content-Length and no chunked Transfer-Encoding on this response # (the whole point is to stream an open-ended sequence of frames), so # on an HTTP/1.1 connection the client has no way to know where the # response ends unless we close the socket ourselves. Force # Connection: close and tell BaseHTTPRequestHandler to close the # connection after this request instead of keeping it alive. self.close_connection = True self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.send_header("Connection", "close") self.end_headers() for event in _SSE_EVENTS: if event["id"] <= last_event_id: continue # already delivered before the reconnect; skip frame = ( f"id: {event['id']}\n" f"event: {event['event']}\n" f"data: {json.dumps(event['data'])}\n\n" ) try: self.wfile.write(frame.encode("utf-8")) self.wfile.flush() except (BrokenPipeError, ConnectionResetError): return # client dropped mid-stream; nothing more to do time.sleep(0.05) # force incremental delivery, one frame at a timedef _run_sse(port: int) -> None: server = ThreadingHTTPServer(("127.0.0.1", port), _SSEHandler) print(f"MCP SSE transport listening on http://127.0.0.1:{port}", file=sys.stderr) server.serve_forever()def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--transport", choices=["stdio", "http", "sse"], default="stdio", help="Transport to run (default: stdio).", ) parser.add_argument( "--port", type=int, default=8787, help="Port for the http/sse transports (default: 8787).", ) args = parser.parse_args() if args.transport == "stdio": mcp.run() elif args.transport == "http": _run_http(args.port) else: _run_sse(args.port)if __name__ == "__main__": main()
The MCP Inspector ships a CLI mode built for exactly this, no browser UI required, which makes it scriptable in CI. Here's a runnable check script that starts the server over stdio, lists its tools, calls one, and asserts on the JSON-RPC shape with jq:
#!/usr/bin/env bash## Inspector-CLI protocol checks for the MCP server in src/server.py.## Drives the server over its default stdio transport via# `@modelcontextprotocol/inspector --cli` (Node 18+, so `npx` is available)# and asserts on the JSON-RPC responses with `jq`:## 1. tools/list returns both tools (get_weather, create_ticket) with# non-empty schemas.# 2. tools/call on get_weather for a known city returns content that# mentions "temperature".## Exits non-zero on the first failed assertion.## Prereqs: Node 18+, jq.# Usage: bash scripts/inspector_checks.shset -euo pipefailSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"SERVER_ENTRY="${REPO_ROOT}/src/server.py"PYTHON_BIN="${PYTHON_BIN:-python3}"fail() { echo "FAIL: $1" >&2 exit 1}command -v npx >/dev/null 2>&1 || fail "npx not found (need Node 18+)"command -v jq >/dev/null 2>&1 || fail "jq not found"command -v "${PYTHON_BIN}" >/dev/null 2>&1 || fail "${PYTHON_BIN} not found"inspector_cli() { npx --yes @modelcontextprotocol/inspector --cli "${PYTHON_BIN}" "${SERVER_ENTRY}" "$@"}echo "==> tools/list"TOOLS_LIST_JSON="$(inspector_cli --method tools/list)"echo "${TOOLS_LIST_JSON}" | jq -e '(.tools // .) | length >= 2' >/dev/null \ || fail "tools/list did not return at least 2 tools"echo "${TOOLS_LIST_JSON}" | jq -e ' (.tools // .) as $tools | ($tools | any(.name == "get_weather")) and ($tools | any(.name == "create_ticket"))' >/dev/null || fail "tools/list is missing get_weather and/or create_ticket"echo "${TOOLS_LIST_JSON}" | jq -e ' (.tools // .) as $tools | all($tools[]; .inputSchema != null and (.inputSchema | length) > 0)' >/dev/null || fail "one or more tools has an empty inputSchema"echo "OK: both tools present with non-empty schemas"echo "==> tools/call get_weather (Austin)"CALL_JSON="$(inspector_cli --method tools/call --tool-name get_weather --tool-arg city=Austin)"echo "${CALL_JSON}" | jq -e ' (.content // []) | any(.text // "" | test("temperature"; "i"))' >/dev/null || fail "tools/call result content did not mention 'temperature'"echo "OK: get_weather(Austin) response mentions 'temperature'"echo "All inspector CLI checks passed."
Run it and you get a real pass or fail, not a visual inspection. If tools/list comes back missing a schema field, or tools/call returns a result without a content array, the script exits non-zero and names the field that broke. That's the whole point of testing this layer separately: a broken handshake produces the exact same symptom to an end user as a bad tool-selection decision ("nothing happened"), but the fix is completely different, so you want to be able to rule this layer out in seconds.
Layer 2: Deterministic Unit Tests on the Tool Functions
Once the protocol layer is confirmed sound, the next question has nothing to do with MCP at all: does the tool's underlying function do the right thing. This is the layer most teams already know how to write, and the mistake most MCP guides make is skipping straight past it to talk about "testing the AI," as if the plain old function underneath weren't there.
Here are the two tool functions themselves, written so they can be imported and called directly, independent of any MCP framing:
"""Plain-Python tool implementations backing the MCP server.These functions have no dependency on the `mcp` package, Claude, or anyLLM. They are imported directly by:- `src/server.py`, which wraps them as MCP tools, and- `tests/test_tools_unit.py`, which unit tests them with exact-match assertions (the fully deterministic layer of the testing pyramid described in the companion blog post).Keeping this logic MCP-agnostic is what makes it cheaply anddeterministically testable: no protocol, no transport, no LLM in theloop."""from __future__ import annotationsimport itertoolsfrom typing import Dict_KNOWN_CITIES: Dict[str, Dict[str, object]] = { "san francisco": {"temperature_f": 62, "conditions": "foggy"}, "new york": {"temperature_f": 74, "conditions": "partly cloudy"}, "austin": {"temperature_f": 91, "conditions": "sunny"}, "london": {"temperature_f": 58, "conditions": "light rain"}, "tokyo": {"temperature_f": 79, "conditions": "clear"},}_ticket_id_counter = itertools.count(1)def get_weather(city: str) -> dict: """Return a deterministic weather reading for a known city. Args: city: City name, case-insensitive (e.g. "Austin", "san francisco"). Returns: A dict with keys `city`, `temperature_f`, and `conditions`. Raises: ValueError: if `city` is empty/whitespace or not in the known city lookup table. """ if not isinstance(city, str) or not city.strip(): raise ValueError("city must be a non-empty string") key = city.strip().lower() if key not in _KNOWN_CITIES: raise ValueError(f"unknown city: {city!r}") reading = _KNOWN_CITIES[key] return { "city": city.strip(), "temperature_f": reading["temperature_f"], "conditions": reading["conditions"], }def create_ticket(title: str, description: str = "") -> dict: """Create an in-memory support ticket. Args: title: Ticket title. Must be non-empty after stripping whitespace. description: Optional free-text description. Returns: A dict with keys `id`, `title`, `description`, and `status`. `status` is always `"open"` for newly created tickets. `id` is unique for the lifetime of the process. Raises: ValueError: if `title` is empty or only whitespace. """ if not isinstance(title, str) or not title.strip(): raise ValueError("title must be a non-empty string") ticket_id = f"TCKT-{next(_ticket_id_counter):04d}" return { "id": ticket_id, "title": title.strip(), "description": description.strip() if isinstance(description, str) else "", "status": "open", }
And here's the pytest suite against them, calling the functions directly and asserting on return values, error handling, and edge cases, with no LLM and no protocol layer anywhere in the loop:
"""Deterministic unit tests for the plain-Python tool functions.These import directly from src/tools.py — no MCP protocol, no transport, noLLM in the loop — so every assertion here is an exact match. This is thefastest and most reliable layer of the testing pyramid described in thecompanion blog post; everything else in this repo builds on top of it.Run: pytest tests/test_tools_unit.py -v"""from __future__ import annotationsimport pytestfrom src.tools import create_ticket, get_weatherclass TestGetWeather: def test_known_city_returns_expected_shape(self) -> None: result = get_weather("Austin") assert result == { "city": "Austin", "temperature_f": 91, "conditions": "sunny", } def test_known_city_is_case_insensitive(self) -> None: result = get_weather("aUsTiN") assert result["city"] == "aUsTiN" assert result["temperature_f"] == 91 assert result["conditions"] == "sunny" def test_known_city_strips_whitespace(self) -> None: result = get_weather(" Tokyo ") assert result["city"] == "Tokyo" assert result["temperature_f"] == 79 def test_unknown_city_raises_value_error(self) -> None: with pytest.raises(ValueError, match="unknown city"): get_weather("Atlantis") def test_empty_city_raises_value_error(self) -> None: with pytest.raises(ValueError, match="non-empty string"): get_weather("") def test_whitespace_only_city_raises_value_error(self) -> None: with pytest.raises(ValueError, match="non-empty string"): get_weather(" ")class TestCreateTicket: def test_valid_ticket_has_expected_shape(self) -> None: ticket = create_ticket("Login button broken", "Clicking does nothing") assert ticket["title"] == "Login button broken" assert ticket["description"] == "Clicking does nothing" assert ticket["status"] == "open" assert ticket["id"].startswith("TCKT-") def test_description_defaults_to_empty_string(self) -> None: ticket = create_ticket("Some bug") assert ticket["description"] == "" def test_title_is_stripped(self) -> None: ticket = create_ticket(" Trailing space title ") assert ticket["title"] == "Trailing space title" def test_empty_title_raises_value_error(self) -> None: with pytest.raises(ValueError, match="non-empty string"): create_ticket("") def test_whitespace_only_title_raises_value_error(self) -> None: with pytest.raises(ValueError, match="non-empty string"): create_ticket(" ") def test_ids_are_unique_across_calls(self) -> None: ids = {create_ticket(f"ticket {i}")["id"] for i in range(10)} assert len(ids) == 10
This suite runs in milliseconds and belongs in your normal CI pipeline, not in some separate "AI testing" track. If create_ticket silently accepts an empty title, or get_weather throws an unhandled exception on an unrecognized city instead of returning a structured error, this is where that gets caught, before you've spent any time wondering whether the model picked the wrong tool.
Layer 3: Does the LLM Actually Pick the Right Tool
This is the layer that doesn't exist in traditional API testing, and it's the one most guides either skip or wave at with a sentence. Given a natural-language prompt, does the model select the correct tool, with correct arguments, reliably enough to trust in production.
"Reliably enough" is the operative phrase, because a single run tells you almost nothing. A tool-selection test that asserts once and calls itself done is really just checking that the happy path is possible, not that it's dependable. The pattern that actually works: run the same scenario N times (10 to 20 is a reasonable floor), and assert on semantic or property-based conditions rather than an exact match, because "the model called create_ticket with a title containing the word 'refund'" is a testable property, while "the model's arguments exactly equal this hardcoded dict" is a test that breaks the moment the model rephrases a field for no functional reason.
Here's an eval harness that does exactly this against the two tools from Layer 2, calling the model directly, running each scenario N times, and asserting on tool name plus argument properties instead of exact values:
"""Tool-selection eval harness for the MCP tools in src/tools.py.Unlike tests/test_tools_unit.py (exact-match, deterministic), this harnessmeasures whether an LLM reliably chooses the *correct* tool and reasonablearguments for a natural-language prompt. LLM tool-calling is probabilistic,so each scenario is run multiple times (N=15) and scored as a pass raterather than a single pass/fail. Scenarios with a pass rate below thethreshold are flagged as flaky.Usage: export ANTHROPIC_API_KEY=sk-ant-... python eval/eval_tool_selection.pyPrereqs: pip install anthropic"""from __future__ import annotationsimport osimport sysfrom dataclasses import dataclass, fieldfrom typing import Any, Callable, Dict, Listimport anthropicMODEL = "claude-opus-4-8"RUNS_PER_SCENARIO = 15PASS_RATE_THRESHOLD = 0.90TOOLS: List[Dict[str, Any]] = [ { "name": "get_weather", "description": "Get current weather conditions for a supported city.", "input_schema": { "type": "object", "properties": {"city": {"type": "string", "description": "City name"}}, "required": ["city"], }, }, { "name": "create_ticket", "description": "Create a support ticket for a reported issue.", "input_schema": { "type": "object", "properties": { "title": {"type": "string", "description": "Short ticket title"}, "description": {"type": "string", "description": "Longer description"}, }, "required": ["title"], }, },]@dataclassclass Scenario: name: str prompt: str expected_tool: str check_args: Callable[[Dict[str, Any]], bool]def _nonempty(value: Any) -> bool: return isinstance(value, str) and len(value.strip()) > 0SCENARIOS: List[Scenario] = [ Scenario( name="weather_direct", prompt="What's the weather like in Austin right now?", expected_tool="get_weather", check_args=lambda args: "austin" in str(args.get("city", "")).lower(), ), Scenario( name="weather_indirect", prompt="I'm packing for a trip to Tokyo tomorrow, should I bring a jacket?", expected_tool="get_weather", check_args=lambda args: "tokyo" in str(args.get("city", "")).lower(), ), Scenario( name="ticket_bug_report", prompt=( "The checkout page throws a 500 error whenever I apply a discount " "code. Can you log this as an issue for the team?" ), expected_tool="create_ticket", check_args=lambda args: _nonempty(args.get("title")), ), Scenario( name="ticket_feature_request", prompt="Please file a ticket asking for dark mode support in the dashboard.", expected_tool="create_ticket", check_args=lambda args: _nonempty(args.get("title")), ),]@dataclassclass ScenarioResult: name: str passes: int = 0 failures: List[str] = field(default_factory=list) @property def total(self) -> int: return self.passes + len(self.failures) @property def pass_rate(self) -> float: return self.passes / self.total if self.total else 0.0def _run_once(client: "anthropic.Anthropic", scenario: Scenario) -> "str | None": """Return a failure reason string, or None if the scenario passed.""" response = client.messages.create( model=MODEL, max_tokens=512, tools=TOOLS, messages=[{"role": "user", "content": scenario.prompt}], ) tool_uses = [block for block in response.content if block.type == "tool_use"] if not tool_uses: return "no tool_use block in response" if len(tool_uses) > 1: return f"expected exactly one tool call, got {len(tool_uses)}" call = tool_uses[0] if call.name != scenario.expected_tool: return f"expected tool {scenario.expected_tool!r}, got {call.name!r}" if not scenario.check_args(call.input): return f"argument check failed for input {call.input!r}" return Nonedef main() -> int: if not os.environ.get("ANTHROPIC_API_KEY"): print("ERROR: ANTHROPIC_API_KEY is not set.", file=sys.stderr) return 2 client = anthropic.Anthropic() results: List[ScenarioResult] = [] for scenario in SCENARIOS: result = ScenarioResult(name=scenario.name) for _ in range(RUNS_PER_SCENARIO): failure = _run_once(client, scenario) if failure is None: result.passes += 1 else: result.failures.append(failure) results.append(result) status = "OK" if result.pass_rate >= PASS_RATE_THRESHOLD else "FLAKY" print( f"[{status}] {scenario.name}: {result.passes}/{result.total} " f"({result.pass_rate:.0%})" ) if status == "FLAKY": for reason in result.failures[:3]: print(f" - {reason}") flaky = [r for r in results if r.pass_rate < PASS_RATE_THRESHOLD] print() if flaky: print( f"{len(flaky)}/{len(results)} scenario(s) below " f"{PASS_RATE_THRESHOLD:.0%} pass rate." ) return 1 print(f"All {len(results)} scenarios at or above {PASS_RATE_THRESHOLD:.0%} pass rate.") return 0if __name__ == "__main__": raise SystemExit(main())
When a scenario in this harness fails intermittently, resist the urge to read that as "the model is unreliable" and move on. Flakiness here is a signal about your test, not a verdict on the model: either the assertion is stricter than the tool actually needs to be (you're checking an exact phrase where a substring or a type check would do), or the tool's description and the prompt are ambiguous enough that a reasonable model could go either way. Tighten the tool description or loosen the assertion, then re-run. This same category of problem, testing whether an agent picks the right tool out of several plausible ones in the first place, is worth its own dedicated pass if your server exposes more than a couple of tools.
A suite that's green across all three of these layers tells you something real: the handshake works, the functions are correct in isolation, and the model reliably picks the right one. It does not tell you the one thing your users actually experience, which is whether calling that tool changed anything correctly inside the application it was supposed to act on.
How Autonoma Verifies What MCP Tool Calls Actually Do
Every layer above stops at the same boundary: it confirms that a tool call was well-formed and that the response looked valid. A create_ticket call that returns {"status": "success", "id": "T-4821"} passes Layer 1 (valid JSON-RPC), Layer 2 (the function returned the shape it's supposed to), and Layer 3 (the model picked the right tool with the right arguments). None of that confirms a ticket actually exists in the system a user will look at next, with the right title, assigned to the right queue, visible to the right account. Testing the tool call is not the same as testing the outcome.
That's the layer Autonoma covers, and it's deliberately outside the three layers above rather than a fourth version of them. Autonoma's Planner reads your codebase (routes, components, the flows a tool call is supposed to trigger) and plans end-to-end test cases around what should happen in the running application, including generating the database state each scenario needs. The Executor runs those planned tests against a live PreviewKit preview environment per PR, driving the actual UI the way a user would after that tool call fired. The Reviewer then classifies what it finds, a real bug in the resulting application state, an agent execution error, or a mismatch between the plan and what changed, and decides whether the plan needs to adapt or whether the failure is real. Diffs Agent keeps the whole suite current as the codebase evolves, so the coverage doesn't quietly rot the next time a tool's behavior changes.
Mapped against the structure of this guide: Layers 1 through 3 answer "did the MCP layer behave correctly." Autonoma answers the question none of them can, which is "did the thing the tool call was supposed to do actually happen, correctly, inside the application." Both answers matter. Neither one substitutes for the other.
Testing Transport-Specific Behavior: stdio, HTTP, and SSE
Everything so far has been transport-agnostic on the surface, but the transport your MCP server runs over changes what can actually go wrong, and almost nothing published on MCP testing addresses this directly. A test suite that only exercises stdio locally and never touches an HTTP or SSE deployment is missing entire categories of production failure.
Same tool call, three different ways to break: a zombie process on stdio, a missing 401 on HTTP, a connection that never resumes on SSE.
Transport
Lifecycle Model
Auth Mechanism
Failure Mode
stdio
Parent-spawned subprocess
Process-level trust
Zombie process, no exit check
HTTP
Stateless request/response
Bearer token per request
Missing 401, stale token accepted
SSE
Long-lived event stream
Token at connect, per-event checks
No reconnect, missed events
stdio: Process Lifecycle Tests
For stdio, the server is a subprocess your client owns. The failure modes are process failure modes: does the process actually start and complete the handshake, does a tool call over stdio return before the process itself is killed, and critically, does the process exit cleanly when the client disconnects instead of leaking a zombie that slowly exhausts file descriptors in a long-running host.
"""pytest suite exercising the server's default stdio transport end-to-end.Spawns src/server.py as a stdio subprocess (the same way MCP Inspector andany stdio-based MCP client would) and asserts: 1. The initialize handshake completes within a timeout. 2. A tools/call succeeds while the server is alive. 3. After the client disconnects, the server process exits with code 0 within a short timeout (no zombie process left behind).Run: pytest tests/test_transport_stdio.py -vPrereqs: pip install pytest mcp"""from __future__ import annotationsimport asyncioimport osimport subprocessimport sysimport timeimport pytestfrom mcp import ClientSession, StdioServerParametersfrom mcp.client.stdio import stdio_clientREPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))SERVER_PATH = os.path.join(REPO_ROOT, "src", "server.py")HANDSHAKE_TIMEOUT = 10.0EXIT_TIMEOUT = 5.0async def _initialize_and_call_tool(): """Run the full stdio handshake + a tools/call, and return the result.""" server_params = StdioServerParameters(command=sys.executable, args=[SERVER_PATH]) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await asyncio.wait_for(session.initialize(), timeout=HANDSHAKE_TIMEOUT) result = await asyncio.wait_for( session.call_tool("get_weather", {"city": "Austin"}), timeout=HANDSHAKE_TIMEOUT, ) return resultclass TestStdioHandshakeAndToolCall: def test_initialize_and_tool_call_succeed(self) -> None: result = asyncio.run(_initialize_and_call_tool()) assert result is not None assert not getattr(result, "isError", False) texts = [block.text for block in result.content if block.type == "text"] assert any("temperature" in text for text in texts)class TestStdioProcessExitsCleanly: """Verifies the server process exits (no zombie) once the client disconnects, using a directly-managed subprocess so the exit code is observable to this test. """ @pytest.fixture def server_process(self): proc = subprocess.Popen( [sys.executable, SERVER_PATH], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: yield proc finally: # Guaranteed teardown even if the test body raises an assertion. if proc.poll() is None: proc.terminate() try: proc.wait(timeout=EXIT_TIMEOUT) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=EXIT_TIMEOUT) def test_process_exits_after_stdin_closed(self, server_process) -> None: # Give the server a moment to finish starting up before disconnecting. time.sleep(0.5) server_process.stdin.close() deadline = time.monotonic() + EXIT_TIMEOUT exit_code = None while time.monotonic() < deadline: exit_code = server_process.poll() if exit_code is not None: break time.sleep(0.1) assert exit_code is not None, "server process did not exit after stdin closed" assert exit_code == 0, f"server process exited with code {exit_code}"
HTTP: Request, Response, and Auth Header Tests
Over HTTP the server is stateless per request, which means every single request needs to independently prove it's authorized. The tests that matter here check the boundary conditions your happy-path integration test never touches: a request with no Authorization header, a request with an expired token, a request with a token that's valid but scoped to fewer tools than it's asking for.
"""pytest + httpx suite against the HTTP-transport instance of src/server.py.This module starts `python src/server.py --transport http --port 8787`itself (module-scoped fixture) so `pytest tests/test_transport_http.py -v`works standalone with no manual setup step. Covers: - No Authorization header -> 401 - Invalid bearer token -> 401 - Valid token -> 200 + a well-formed tools/list - A second, narrower-scoped token -> tools/list containing only its scoped toolsThe two bearer tokens below are HS256 JWTs pre-signed with the sharedsecret in src/auth.py (`JWT_SECRET`), hardcoded here so this test filedoesn't need the `pyjwt` dependency (only pytest + httpx, per the spec).Each has a far-future `exp` (year ~2099) so they never expire. Seetests/test_auth.py for the JWT-based expiry/refresh suite, which does usepyjwt to mint tokens dynamically.Run: pytest tests/test_transport_http.py -vPrereqs: pip install pytest httpx"""from __future__ import annotationsimport osimport subprocessimport sysimport timeimport httpximport pytestREPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))SERVER_PATH = os.path.join(REPO_ROOT, "src", "server.py")PORT = 8787BASE_URL = f"http://127.0.0.1:{PORT}"STARTUP_TIMEOUT = 10.0# Pre-signed HS256 JWTs (see module docstring). Scopes:# FULL_ACCESS_TOKEN -> get_weather, create_ticket# WEATHER_ONLY_TOKEN -> get_weather onlyFULL_ACCESS_TOKEN = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJzdWIiOiJmdWxsLWFjY2Vzcy10ZXN0Iiwic2NvcGUiOiJnZXRfd2VhdGhlciBjcmVhdGVfdGlja2V0IiwiZXhwIjo0MDg5MDAwMDAwfQ." "sfNcRB4Pgs6CYRNzxq5TtQeXgf-FjA7bgt6aY6F7baY")WEATHER_ONLY_TOKEN = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJzdWIiOiJ3ZWF0aGVyLW9ubHktdGVzdCIsInNjb3BlIjoiZ2V0X3dlYXRoZXIiLCJleHAiOjQwODkwMDAwMDB9." "mtms-f8F6v2KB-RfG-TsyCoNPWwx-1-s1CZMFPMTwP0")@pytest.fixture(scope="module")def http_server(): proc = subprocess.Popen( [sys.executable, SERVER_PATH, "--transport", "http", "--port", str(PORT)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: deadline = time.monotonic() + STARTUP_TIMEOUT last_error: Exception | None = None while time.monotonic() < deadline: if proc.poll() is not None: stderr = proc.stderr.read().decode("utf-8", errors="replace") raise RuntimeError(f"server exited early:\n{stderr}") try: httpx.post(BASE_URL, json={}, timeout=0.5) break except httpx.TransportError as exc: last_error = exc time.sleep(0.1) else: raise RuntimeError(f"server did not start in time: {last_error}") yield BASE_URL finally: # Guaranteed teardown even if a test assertion fails. if proc.poll() is None: proc.terminate() try: proc.wait(timeout=5.0) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=5.0)def _rpc(base_url: str, method: str, params: dict, token: "str | None" = None): headers = {"Authorization": f"Bearer {token}"} if token else {} return httpx.post( base_url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, headers=headers, timeout=5.0, )class TestHTTPAuth: def test_no_authorization_header_returns_401(self, http_server: str) -> None: response = _rpc(http_server, "tools/list", {}) assert response.status_code == 401 def test_invalid_bearer_token_returns_401(self, http_server: str) -> None: response = _rpc(http_server, "tools/list", {}, token="not-a-real-token") assert response.status_code == 401 def test_valid_token_returns_well_formed_tools_list(self, http_server: str) -> None: response = _rpc(http_server, "tools/list", {}, token=FULL_ACCESS_TOKEN) assert response.status_code == 200 body = response.json() tools = body["result"]["tools"] names = {tool["name"] for tool in tools} assert names == {"get_weather", "create_ticket"} for tool in tools: assert tool["description"] assert tool["inputSchema"]["type"] == "object" def test_scoped_token_only_lists_its_own_tools(self, http_server: str) -> None: response = _rpc(http_server, "tools/list", {}, token=WEATHER_ONLY_TOKEN) assert response.status_code == 200 body = response.json() names = {tool["name"] for tool in body["result"]["tools"]} assert names == {"get_weather"}
SSE: Event-Stream Parsing and Reconnection Tests
SSE looks like HTTP until the connection drops. The two things worth testing directly: that your client correctly parses incremental data: frames as they arrive rather than waiting for the stream to close, and that a dropped connection actually reconnects and resumes rather than silently going quiet. This second one is the failure mode that never shows up in local development and always shows up in production behind a load balancer.
"""pytest + httpx streaming suite against the SSE-transport instance ofsrc/server.py.ASSUMPTION (documented up front, as this test relies on it): the SSEtransport's event sequence is a small, fixed, in-memory list(`_SSE_EVENTS` in src/server.py) that is replayed identically on everyconnection. Resumption via `Last-Event-ID` works by skipping events with`id <= Last-Event-ID` from that same fixed sequence. This is ateaching-scale stand-in for "server-side event-id persistence" -- a realproduction server would persist delivered-event state per client/stream ina durable store rather than replaying a hardcoded in-memory list, but theresumption *contract* being tested (reconnect with Last-Event-ID; noduplicate delivery of already-processed events) is the same either way.This module starts `python src/server.py --transport sse --port 8787`itself (module-scoped fixture) so `pytest tests/test_transport_sse.py -v`works standalone with no manual setup step. Covers: - Incremental frame parsing: the client observes early events without waiting for the stream to close. - Mid-stream dropped connection: reconnecting with `Last-Event-ID` resumes from the next undelivered event and does not redeliver an already-processed event.Run: pytest tests/test_transport_sse.py -vPrereqs: pip install pytest httpx"""from __future__ import annotationsimport osimport subprocessimport sysimport timefrom typing import Iterator, Optional, Tupleimport httpximport pytestREPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))SERVER_PATH = os.path.join(REPO_ROOT, "src", "server.py")PORT = 8788BASE_URL = f"http://127.0.0.1:{PORT}"STARTUP_TIMEOUT = 10.0# Same shared-secret HS256 JWT used in tests/test_transport_http.py, hardcoded# here too so this file stays self-contained and doesn't need pyjwt. Grants# both tool scopes; the SSE transport doesn't scope by tool, only by whether# the token is valid at all.FULL_ACCESS_TOKEN = ( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJzdWIiOiJmdWxsLWFjY2Vzcy10ZXN0Iiwic2NvcGUiOiJnZXRfd2VhdGhlciBjcmVhdGVfdGlja2V0IiwiZXhwIjo0MDg5MDAwMDAwfQ." "sfNcRB4Pgs6CYRNzxq5TtQeXgf-FjA7bgt6aY6F7baY")@pytest.fixture(scope="module")def sse_server(): proc = subprocess.Popen( [sys.executable, SERVER_PATH, "--transport", "sse", "--port", str(PORT)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: deadline = time.monotonic() + STARTUP_TIMEOUT last_error: Optional[Exception] = None while time.monotonic() < deadline: if proc.poll() is not None: stderr = proc.stderr.read().decode("utf-8", errors="replace") raise RuntimeError(f"server exited early:\n{stderr}") try: with httpx.stream( "GET", BASE_URL, headers={"Authorization": f"Bearer {FULL_ACCESS_TOKEN}"}, timeout=0.5, ) as response: response.read() break except httpx.TransportError as exc: last_error = exc time.sleep(0.1) else: raise RuntimeError(f"server did not start in time: {last_error}") yield BASE_URL finally: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=5.0) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=5.0)def _iter_sse_events(response: httpx.Response) -> Iterator[Tuple[int, str, dict]]: """Parse `id:` / `event:` / `data:` frames, yielding (id, event, data).""" event_id: Optional[int] = None event_name: Optional[str] = None data_line: Optional[str] = None for line in response.iter_lines(): if line == "": if event_id is not None and event_name is not None and data_line is not None: import json as _json yield event_id, event_name, _json.loads(data_line) event_id = event_name = data_line = None continue if line.startswith("id: "): event_id = int(line[len("id: ") :]) elif line.startswith("event: "): event_name = line[len("event: ") :] elif line.startswith("data: "): data_line = line[len("data: ") :]class TestSSEIncrementalDelivery: def test_first_event_observed_well_before_stream_closes( self, sse_server: str ) -> None: start = time.monotonic() time_to_first: Optional[float] = None received_ids = [] with httpx.stream( "GET", sse_server, headers={"Authorization": f"Bearer {FULL_ACCESS_TOKEN}"}, timeout=10.0, ) as response: assert response.status_code == 200 for event_id, _event_name, _data in _iter_sse_events(response): if time_to_first is None: time_to_first = time.monotonic() - start received_ids.append(event_id) total_time = time.monotonic() - start assert received_ids == [1, 2, 3, 4, 5] assert time_to_first is not None # The first event must arrive well before the full stream finishes -- # proof the client parsed it incrementally rather than waiting for # the connection to close. The server paces frames ~0.05s apart, so # the first of five frames should land in well under half the total # stream duration. assert time_to_first < total_time * 0.6class TestSSEReconnectResume: def test_reconnect_with_last_event_id_does_not_duplicate( self, sse_server: str ) -> None: received_before_drop = [] # Connect, read the first two events, then abandon the connection # (simulating a mid-stream drop) before the server finishes sending. with httpx.stream( "GET", sse_server, headers={"Authorization": f"Bearer {FULL_ACCESS_TOKEN}"}, timeout=10.0, ) as response: for event_id, _event_name, _data in _iter_sse_events(response): received_before_drop.append(event_id) if len(received_before_drop) == 2: break # drop the connection early assert received_before_drop == [1, 2] # Reconnect with Last-Event-ID set to the last event we actually # processed. The server must resume at the next event and must not # redeliver 1 or 2. received_after_reconnect = [] with httpx.stream( "GET", sse_server, headers={ "Authorization": f"Bearer {FULL_ACCESS_TOKEN}", "Last-Event-ID": str(received_before_drop[-1]), }, timeout=10.0, ) as response: assert response.status_code == 200 for event_id, _event_name, _data in _iter_sse_events(response): received_after_reconnect.append(event_id) assert received_after_reconnect == [3, 4, 5] assert set(received_before_drop).isdisjoint(received_after_reconnect)
Testing Authorization and Token Handling
The last gap almost nothing covers: does your MCP server actually enforce who's allowed to call what. Three patterns matter, and they're distinct enough to need separate tests.
First, rejection: a tool call with no credential, or an invalid one, should be refused before any tool logic runs, not caught by a downstream error handler. Second, scoping: two valid credentials with different permissions should see different tool lists and different results, not the same tools with a client-side filter that a slightly different request could bypass. Third, expiry and refresh: a token that was valid when the connection opened but expires mid-session should cause the next call to fail cleanly, and a refreshed token should be picked up without requiring a full reconnect.
"""pytest suite covering the HTTP-transport server's auth boundary in depth:rejection, scope enforcement, and token expiry/refresh.Shared test signing secret: this file signs its own JWTs with the literalsecret below, which MUST match `JWT_SECRET` in src/auth.py exactly --that's what makes a token minted here acceptable to the server under test.It's a plaintext demo secret on purpose (see src/auth.py's moduledocstring); never reuse it, or this pattern, for a real deployment.Run: pytest tests/test_auth.py -vPrereqs: pip install pytest httpx pyjwt"""from __future__ import annotationsimport osimport subprocessimport sysimport timefrom typing import Optionalimport httpximport jwtimport pytestREPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))SERVER_PATH = os.path.join(REPO_ROOT, "src", "server.py")PORT = 8789BASE_URL = f"http://127.0.0.1:{PORT}"STARTUP_TIMEOUT = 10.0# Must match src/auth.py's JWT_SECRET / JWT_ALGORITHM exactly.JWT_SECRET = "mcp-testing-demo-shared-secret-do-not-use-in-prod"JWT_ALGORITHM = "HS256"FAR_FUTURE_EXP = 4_089_000_000 # ~year 2099def _make_token(scope: str, exp: float) -> str: return jwt.encode( {"sub": "test-token", "scope": scope, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGORITHM, )@pytest.fixture(scope="module")def auth_server(): proc = subprocess.Popen( [sys.executable, SERVER_PATH, "--transport", "http", "--port", str(PORT)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) try: deadline = time.monotonic() + STARTUP_TIMEOUT last_error: Optional[Exception] = None while time.monotonic() < deadline: if proc.poll() is not None: stderr = proc.stderr.read().decode("utf-8", errors="replace") raise RuntimeError(f"server exited early:\n{stderr}") try: httpx.post(BASE_URL, json={}, timeout=0.5) break except httpx.TransportError as exc: last_error = exc time.sleep(0.1) else: raise RuntimeError(f"server did not start in time: {last_error}") yield BASE_URL finally: if proc.poll() is None: proc.terminate() try: proc.wait(timeout=5.0) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=5.0)def _call_tool(base_url: str, tool_name: str, arguments: dict, token: Optional[str], client: Optional[httpx.Client] = None): headers = {"Authorization": f"Bearer {token}"} if token else {} body = { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool_name, "arguments": arguments}, } if client is not None: return client.post(base_url, json=body, headers=headers, timeout=5.0) return httpx.post(base_url, json=body, headers=headers, timeout=5.0)class TestRejection: def test_no_token_is_rejected_with_no_tool_side_effect(self, auth_server: str) -> None: response = _call_tool( auth_server, "create_ticket", {"title": "should not be created"}, token=None ) assert response.status_code in (401, 403) body = response.json() assert "result" not in body def test_invalid_token_is_rejected_with_no_tool_side_effect(self, auth_server: str) -> None: response = _call_tool( auth_server, "create_ticket", {"title": "should not be created either"}, token="garbage.not.a.jwt", ) assert response.status_code in (401, 403) body = response.json() assert "result" not in bodyclass TestScoping: def test_weather_only_token_succeeds_on_get_weather(self, auth_server: str) -> None: token = _make_token(scope="get_weather", exp=FAR_FUTURE_EXP) response = _call_tool(auth_server, "get_weather", {"city": "London"}, token=token) assert response.status_code == 200 assert response.json()["result"] is not None def test_weather_only_token_gets_403_on_create_ticket(self, auth_server: str) -> None: token = _make_token(scope="get_weather", exp=FAR_FUTURE_EXP) response = _call_tool( auth_server, "create_ticket", {"title": "nope"}, token=token ) assert response.status_code == 403 def test_ticket_only_token_succeeds_on_create_ticket(self, auth_server: str) -> None: token = _make_token(scope="create_ticket", exp=FAR_FUTURE_EXP) response = _call_tool( auth_server, "create_ticket", {"title": "filed via scoped token"}, token=token ) assert response.status_code == 200 assert response.json()["result"] is not None def test_ticket_only_token_gets_403_on_get_weather(self, auth_server: str) -> None: token = _make_token(scope="create_ticket", exp=FAR_FUTURE_EXP) response = _call_tool(auth_server, "get_weather", {"city": "London"}, token=token) assert response.status_code == 403class TestExpiryAndRefresh: def test_expired_token_is_rejected(self, auth_server: str) -> None: expired_token = _make_token(scope="get_weather", exp=time.time() - 100) response = _call_tool( auth_server, "get_weather", {"city": "London"}, token=expired_token ) assert response.status_code == 401 assert "expired" in response.json()["error"].lower() def test_fresh_replacement_token_succeeds_without_new_connection( self, auth_server: str ) -> None: expired_token = _make_token(scope="get_weather", exp=time.time() - 100) with httpx.Client() as client: rejected = _call_tool( auth_server, "get_weather", {"city": "London"}, token=expired_token, client=client ) assert rejected.status_code == 401 # Same httpx.Client (and therefore, potentially, the same # underlying TCP connection) -- only the bearer token changes. fresh_token = _make_token(scope="get_weather", exp=FAR_FUTURE_EXP) accepted = _call_tool( auth_server, "get_weather", {"city": "London"}, token=fresh_token, client=client ) assert accepted.status_code == 200 assert accepted.json()["result"] is not None
Notice what these tests are not checking: whether the tool call worked. They're checking whether it was allowed to be attempted at all, which is a separate question a lot of MCP servers answer with "we trust whatever's inside the network perimeter," right up until the perimeter has a gap.
Putting the Layers Together
None of these five categories replace each other. Protocol tests catch a broken handshake in seconds without needing a model call. Deterministic unit tests catch a logic bug in the function itself, independent of whether the LLM ever picks it correctly. Tool-selection evals catch the case where everything downstream is correct but the model reaches for the wrong tool, and they're the one layer where a single run is worthless and a stable pass rate across N runs is the actual signal. Transport tests catch the failure modes specific to how your server is actually deployed, not how it behaves on localhost over stdio. Auth tests catch the boundary a working demo never exercises.
Run all five, and you know your MCP server speaks the protocol correctly, does the right thing internally, gets picked correctly by the model calling it, survives its actual transport, and enforces who's allowed to call it. What you still don't know, and what none of these layers can tell you, is whether the actual application behind that tool call ended up in the state your user needed it to be in. Autonoma supplies that final behavioral E2E check against the deployed application, so a green MCP suite can be paired with proof that the user-facing outcome occurred.
Frequently Asked Questions
MCP testing is the practice of verifying a Model Context Protocol server across three layers: the protocol and handshake layer (is the JSON-RPC message shape correct, are tools listed properly), the deterministic layer (do the underlying tool functions return correct values and handle errors), and the tool-selection layer (does a calling LLM pick the right tool with the right arguments given a natural-language prompt). It also covers transport-specific behavior (stdio, HTTP, SSE) and authorization.
Validate MCP responses at the protocol layer with the MCP Inspector CLI, checking that tools/list and tools/call return well-formed JSON-RPC results with the expected fields present. Then validate the same responses at the function layer with deterministic unit tests that call the tool's underlying implementation directly, bypassing the LLM, and assert on exact return values and error handling.
Import the tool's underlying function directly in a unit test and call it with the same arguments an LLM would pass. This tests the deterministic logic (validation, error handling, edge cases) in isolation from tool selection, and it runs in milliseconds instead of requiring a live model call.
Run each tool-selection scenario at least 10 to 20 times before trusting the pass rate, since a single run tells you nothing about consistency. If a scenario fails intermittently, treat that as a signal to loosen an overly strict assertion or clarify an ambiguous tool description and prompt, rather than assuming the model is simply unreliable.
Yes. Each transport fails differently. stdio tests need to check process lifecycle (clean spawn, clean exit, no zombie processes). HTTP tests need to check request and response semantics including auth headers and status codes. SSE tests need to check event-stream parsing and reconnection behavior after a dropped connection. A test suite that only covers one transport will miss failure modes specific to the others.