How to Test MCP Servers: A Practical Guide (2026)
Quick answer
An MCP server is a JSON-RPC 2.0 service, so it is testable like any other API: complete the initialize handshake, assert the declared capabilities, call tools/list and check every tool's input schema, then call each tool with valid, invalid and hostile input. The Inspector covers manual exploration; an automated suite should assert the handshake, the schema of every tool result, error mapping, and that tool descriptions cannot inject instructions into the calling model.
Reviewed by Sushant Joshi
The Model Context Protocol standardises how an LLM application connects to tools and data. Under the branding it is a plain JSON-RPC 2.0 protocol with a capability handshake, which is good news for testers: nothing about it requires new techniques, only a new checklist.
This guide covers what to assert. For how MCP differs conceptually from a REST API, see MCP vs REST APIs for testers; for the security half, securing MCP endpoints.
In this guide
- What an MCP server exposes
- Start with the handshake
- Treat every tool's inputSchema as a contract
- Errors: protocol-level versus tool-level
- The injection surface that has no REST equivalent
- HTTP transport adds the usual API surface back
- Running it in CI
- A minimal test harness to start from
- Resources and prompts are surface too
- Common mistakes when testing an MCP server
- Where to start if the server already exists
- Frequently asked questions about testing MCP servers
What an MCP server exposes
| Primitive | What it is | What to test |
|---|---|---|
| Tools | Callable functions the model can invoke | Input schema validation, side effects, error mapping, idempotency |
| Resources | Readable content the client can fetch | URI handling, access control, content type, size limits |
| Prompts | Reusable prompt templates | Argument substitution, missing-argument handling |
| Capabilities | What the server declares it supports at connect time | That declarations match reality |
| Notifications | Server-initiated messages (e.g. list changed) | That they fire when the underlying state changes |
Start with the handshake
Every session begins with initialize. A server that mishandles it fails every client before a single tool is called, which makes this the highest-value test in the suite.
# stdio transport: the server reads JSON-RPC from stdin
printf '%s\n' '{
"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{
"protocolVersion":"2025-06-18",
"capabilities":{},
"clientInfo":{"name":"test-client","version":"1.0.0"}
}
}' | node build/server.js | jq
# tests/test_handshake.py
import json, subprocess
def rpc(proc, payload):
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
return json.loads(proc.stdout.readline())
def test_initialize_negotiates_and_declares_capabilities(server):
res = rpc(server, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}},
})
assert "error" not in res, res
result = res["result"]
assert result["protocolVersion"], "no protocol version negotiated"
assert "serverInfo" in result and result["serverInfo"]["name"]
# every declared capability must actually be implemented — checked below
server.capabilities = result["capabilities"]
def test_unsupported_protocol_version_is_rejected_cleanly(server):
res = rpc(server, {
"jsonrpc": "2.0", "id": 99, "method": "initialize",
"params": {"protocolVersion": "1999-01-01", "capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}},
})
# either negotiate down to a supported version, or return a structured
# error — what it must not do is accept it silently and behave oddly later
assert "error" in res or res["result"]["protocolVersion"] != "1999-01-01"
def test_declared_capabilities_are_backed_by_real_methods(server):
caps = server.capabilities
if "tools" in caps:
assert "error" not in rpc(server, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
if "resources" in caps:
assert "error" not in rpc(server, {"jsonrpc": "2.0", "id": 3, "method": "resources/list"})
if "prompts" in caps:
assert "error" not in rpc(server, {"jsonrpc": "2.0", "id": 4, "method": "prompts/list"})
That last test catches the most common MCP bug in the wild: a server that advertises a capability it never implemented, so the client offers the user a feature that errors on first use.
Treat every tool's inputSchema as a contract
tools/list returns each tool's name, description and JSON Schema. That schema is the contract, and it is testable exactly like an OpenAPI schema — the techniques in API schema validation and API contract testing transfer directly:
# tests/test_tools.py
import jsonschema, pytest
def test_every_tool_declares_a_valid_schema(tools):
for tool in tools:
assert tool["name"] and tool["description"], f"{tool} is missing metadata"
schema = tool["inputSchema"]
# the schema itself has to be valid JSON Schema, or no client can use it
jsonschema.Draft202012Validator.check_schema(schema)
assert schema.get("type") == "object"
@pytest.mark.parametrize("tool_name", ALL_TOOL_NAMES)
def test_tool_rejects_input_that_violates_its_own_schema(server, tool_name, bad_input):
res = call_tool(server, tool_name, bad_input)
# a structured error, not an exception and not a successful execution
assert res.get("isError") or "error" in res, \
f"{tool_name} accepted input its own schema forbids"
@pytest.mark.parametrize("tool_name", ALL_TOOL_NAMES)
def test_tool_result_content_is_well_formed(server, tool_name, valid_input):
res = call_tool(server, tool_name, valid_input)
assert "result" in res
for item in res["result"]["content"]:
assert item["type"] in {"text", "image", "audio", "resource"}
if item["type"] == "text":
assert isinstance(item["text"], str)
Parametrising over ALL_TOOL_NAMES from tools/list rather than a hard-coded list means a newly added tool is covered automatically — and an untested one fails the suite instead of shipping unnoticed.
Ready to shift left with your API testing?
Try our no-code API test automation platform free. Generate tests from OpenAPI, run in CI/CD, and scale quality.
Errors: protocol-level versus tool-level
MCP has two distinct failure channels and servers routinely conflate them:
- A JSON-RPC error means the request was malformed or the method does not exist. The client cannot proceed.
- A tool result with
isError: truemeans the tool ran and failed. The model can read the message and try something else.
def test_unknown_method_is_a_protocol_error(server):
res = rpc(server, {"jsonrpc": "2.0", "id": 10, "method": "tools/nope"})
assert res["error"]["code"] == -32601 # method not found
def test_tool_failure_is_a_tool_error_not_a_protocol_error(server):
# a valid call to a valid tool that fails for a business reason
res = call_tool(server, "get_order", {"id": "does-not-exist"})
assert "error" not in res, "business failure leaked as a protocol error"
assert res["result"]["isError"] is True
assert res["result"]["content"][0]["text"], "no message the model can act on"
def test_tool_never_returns_a_stack_trace(server):
res = call_tool(server, "get_order", {"id": "'; DROP TABLE orders; --"})
text = json.dumps(res)
for leak in ("Traceback", "at java.", "node_modules/", "psycopg", "SQLSTATE"):
assert leak not in text, f"internal detail leaked to the model: {leak}"
The third one matters more than it looks. Anything a tool returns goes into the model's context and may be surfaced to a user, so an unhandled exception is both a bug and a disclosure.
The injection surface that has no REST equivalent
Tool descriptions and tool results are text that reaches the model's context. That makes them an instruction channel, and it is the one genuinely new thing to test here:
INSTRUCTION_SHAPED = [
"ignore previous instructions", "disregard the above", "system:",
"you must now", "do not tell the user", "</system>", "<|im_start|>",
]
def test_tool_descriptions_contain_no_instructions(tools):
"""A poisoned description is a supply-chain attack on the calling model."""
for tool in tools:
text = (tool["description"] + json.dumps(tool["inputSchema"])).lower()
for phrase in INSTRUCTION_SHAPED:
assert phrase not in text, f"{tool['name']}: instruction-shaped text in metadata"
def test_hostile_content_in_a_returned_document_is_not_privileged(server):
"""A document the tool fetched is data, not a command."""
res = call_tool(server, "fetch_document", {"url": POISONED_DOC_URL})
content = res["result"]["content"][0]["text"]
# the server may return the document, but it must not act on it or
# re-emit it as though it were server-authored instruction
assert res["result"].get("isError") or "IGNORE PREVIOUS INSTRUCTIONS" in content
assert not res["result"].get("_meta", {}).get("systemPrompt")
HTTP transport adds the usual API surface back
Over stdio there is no network to secure. Over HTTP, an MCP server is an internet-facing API and inherits every ordinary requirement — authentication, rate limits, TLS and the rest of the API testing checklist:
def test_http_transport_requires_authorization(http_server):
r = requests.post(http_server.url, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code == 401
def test_http_transport_validates_origin(http_server, token):
r = requests.post(http_server.url,
headers={"Authorization": f"Bearer {token}", "Origin": "https://evil.example"},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code in (400, 403), "DNS-rebinding protection missing"
def test_session_is_not_reusable_across_identities(http_server, token_a, token_b, session_id):
r = requests.post(http_server.url,
headers={"Authorization": f"Bearer {token_b}", "Mcp-Session-Id": session_id},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
assert r.status_code in (401, 403, 404)
Running it in CI
# .github/workflows/mcp-tests.yml
name: MCP server tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm ci && npm run build
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pytest jsonschema requests
- name: Protocol and tool suite
run: pytest tests/ -q --junitxml=results.xml
- uses: actions/upload-artifact@v4
if: always()
with: { name: mcp-results, path: results.xml }
Use the Inspector while developing — npx @modelcontextprotocol/inspector node build/server.js — and keep the suite above as the gate. Same division of labour as an API client and a generated test suite, and it wires into a pipeline the same way as any other suite: see how to automate API testing in CI/CD.
A minimal test harness to start from
Most of the friction in testing an MCP server is process management, not assertions. This fixture handles the lifecycle so the tests stay readable:
# conftest.py
import json, subprocess, pytest
class McpProcess:
def __init__(self, argv):
self.p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, bufsize=1)
self._id = 0
self.capabilities = {}
def rpc(self, method, params=None):
self._id += 1
payload = {"jsonrpc": "2.0", "id": self._id, "method": method}
if params is not None:
payload["params"] = params
self.p.stdin.write(json.dumps(payload) + "\n")
self.p.stdin.flush()
return json.loads(self.p.stdout.readline())
def close(self):
self.p.stdin.close()
self.p.wait(timeout=5)
@pytest.fixture(scope="session")
def server():
s = McpProcess(["node", "build/server.js"])
init = s.rpc("initialize", {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "pytest", "version": "1.0.0"},
})
s.capabilities = init["result"]["capabilities"]
s.rpc("notifications/initialized")
yield s
s.close()
@pytest.fixture(scope="session")
def tools(server):
return server.rpc("tools/list")["result"]["tools"]
def pytest_generate_tests(metafunc):
"""Parametrise over the live tool list so a new tool is covered automatically."""
if "tool" in metafunc.fixturenames:
s = McpProcess(["node", "build/server.js"])
s.rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "collect", "version": "1.0.0"}})
tools = s.rpc("tools/list")["result"]["tools"]
s.close()
metafunc.parametrize("tool", tools, ids=[t["name"] for t in tools])
Free PDF + code examples
OpenAPI to Test Generation Template Pack
Go from OpenAPI spec to full test coverage. Includes sample specs, example generated tests, edge case patterns, and CI/CD integration guides.
Download FreeThe pytest_generate_tests hook is the piece worth copying. Collecting the tool list at collection time means every tool gets its own named test case in the report, so a failure says test_tool_schema[create_order] rather than pointing at a loop.
Resources and prompts are surface too
Tools get the attention because they act. An MCP server can also expose resources — data the client can read — and prompts, which are templates the client can instantiate. Both are part of the contract and both are routinely untested.
Resources deserve the same authorization scrutiny as tools, and often get less, because reading feels safer than writing. It is not: a resource that returns a document the caller should not see is a data breach with no write involved — run the identity matrix from authentication and authorization testing for APIs against every resource URI.
def test_resources_respect_tenant_boundaries(mcp_session, other_tenant_uri):
"""A resource read is an authorization decision, same as a tool call."""
listed = {r.uri for r in mcp_session.list_resources()}
assert other_tenant_uri not in listed, "another tenant's resource is discoverable"
with pytest.raises(McpError) as err:
mcp_session.read_resource(other_tenant_uri)
assert err.value.code in (ErrorCode.INVALID_REQUEST, ErrorCode.INVALID_PARAMS)
Two failure modes are specific to resources. Enumeration — the list itself leaks what exists, even when reading is refused, so assert on what is listed as well as what is readable. And URI traversal — resource URIs are strings a caller can manipulate, so a server that concatenates them into a filesystem or database path has the same class of bug as a path traversal in a web server.
Prompts are lower risk but not zero: a prompt template is text that goes to a model, so a template built from user-controlled input is an injection vector by construction. Assert that templates render with the arguments substituted safely, and that a template cannot be made to emit instructions it was not meant to carry.
Common mistakes when testing an MCP server
Testing only the tools you wrote this sprint. The tool list is the contract, and it changes. Without a snapshot assertion the surface grows silently between releases.
Treating protocol success as tool success. A call can return cleanly at the protocol level while the tool reports a failure in its result. A harness that only checks for protocol errors reports green on genuine failures — this is the single most common gap.
Skipping the unhappy handshake. Servers get sent unsupported protocol versions, malformed initialisation and requests before initialisation completes. Each should fail cleanly rather than hang or crash the process, and none of them happen in normal use, so none of them get exercised by accident.
Assuming a local transport needs less testing. stdio removes network attackers and nothing else. Argument validation, authorization and injection resistance matter exactly as much.
Leaving out concurrency. Multiple tool calls in flight on one session is normal. Servers holding per-session state without isolation produce cross-talk that only appears under concurrent use, and a serial test suite will never see it.
Where to start if the server already exists
Retrofitting tests onto a running MCP server is a different job from writing them alongside one, and the order that gets value fastest is not the order a tutorial suggests.
Start with the snapshot test on the tool list, because it takes minutes and immediately makes every future change to the surface a reviewable event. Then add the authorization tests for the two or three most powerful tools — the ones that write, delete, or reach an upstream system — since that is where an unnoticed gap costs the most. Only then work through argument validation per tool, which is the largest body of work and the least urgent, because a malformed argument usually fails safely while a missing authorization check does not.
Handshake and transport tests come last. They are worth having and they catch the fewest real problems, because a broken handshake is loud and gets fixed on the first day somebody connects.
Frequently asked questions about testing MCP servers
What is an MCP server, in testing terms? A JSON-RPC 2.0 service that exposes tools, resources and prompts to an LLM client over stdio or HTTP. Every method is a request/response pair with a declared schema, which means it is testable with the same techniques you use for any other API.
What is the MCP Inspector? A developer tool that connects to an MCP server and lets you browse and invoke its tools, resources and prompts interactively. It is the equivalent of an API client — good for exploration, not a test suite.
What is the first thing to test? The initialize handshake. A server that negotiates the wrong protocol version, or declares a capability it does not implement, fails every client at connection time, and that is the cheapest bug to catch.
How do I test tool schemas? Call tools/list, then validate each tool's inputSchema as JSON Schema and assert that calling the tool with input that violates it returns a structured error rather than executing anyway.
What is unique to MCP security testing? Tool descriptions and results are text that reaches the model's context, so they are an injection surface no REST API has. Assert that no tool description or result contains instruction-shaped content, and that a hostile payload in a returned document does not change client behaviour.
Does the transport change the tests? The protocol-level tests are identical. What changes is everything around the transport — HTTP servers need authorization, origin validation and session handling tested; stdio servers need process lifecycle and stream framing tested.
Sources and further reading
- Model Context Protocol documentation — the specification, primitives and transports.
- JSON Schema 2020-12 — the dialect tool input schemas are written in.
- OWASP Top 10 for LLM Applications — prompt injection and supply-chain risks that apply directly to tool metadata.
Key takeaways
- An MCP server is a JSON-RPC service with a capability handshake; nothing about testing it requires new techniques, only a new checklist.
- Test
initializefirst — version negotiation and capabilities that are declared but not implemented break every client at connect time. - Parametrise tool tests over
tools/listrather than a hard-coded list, so a new tool is covered automatically and an untested one fails the suite. - Keep protocol errors and tool errors distinct: a business failure that surfaces as a JSON-RPC error is a bug, and a stack trace in a tool result is a disclosure.
- Tool descriptions and results reach the model's context, which makes them an injection surface no REST API has — assert that neither contains instruction-shaped text.
- Over HTTP, all the ordinary API requirements come back: authorization, origin validation and session binding.
Ready to shift left with your API testing?
Try our no-code API test automation platform free.