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.
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:
# 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"
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.
@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.
## 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: true`** means the tool ran and failed. The model can read the message and try something else.
```python
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:
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"
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 Freedef 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
```yaml
# .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.
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])
The 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.
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.
Related articles
MCP vs REST APIs for Testers | Securing MCP Endpoints | How to Test LLM Applications | Testing Non-Deterministic AI Systems | API Testing: The Complete Guide
Ready to shift left with your API testing?
Try our no-code API test automation platform free.