MCP Server Testing¶
The TestClient class lets you exercise the full MCP server pipeline --
input validation, dependency injection, guard checks, middleware chain, handler
invocation, and error serialisation -- without starting a network transport.
Tests run entirely in-process and are as fast as plain function calls.
Source: src/promptise/mcp/server/testing.py and src/promptise/mcp/server/_testing.py
Quick example¶
import pytest
from promptise.mcp.server import MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="test")
@server.tool()
async def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
@pytest.mark.asyncio
async def test_add():
client = TestClient(server)
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result[0].text == "3"
Concepts¶
What TestClient exercises¶
The TestClient replicates the exact same call pipeline as the real MCP
transport. Every tool call goes through:
- Input validation -- arguments are validated against the tool's Pydantic
input model. A call whose arguments are rejected is not answered at this
point: it still runs through the middleware chain (authentication, rate
limiting, audit, in your middleware order) and its guards, and then fails
with a
VALIDATION_ERROR. The handler, dependency injection and approval requests never see the rejected input, and the error names the failing fields and constraints without echoing the value, with at most 20 field errors and a message capped at 1,000 characters. The live transports work the same way: the MCP SDK's owninputSchemacheck is turned off so that an invalid call cannot bypass the pipeline. - Dependency injection --
Depends(...)parameters are resolved. - Context injection -- parameters typed as
RequestContextreceive the current request context. - Guard checks -- registered guards (
RequireAuth,HasRole, etc.) are evaluated. - Middleware chain -- server-level and router-level middleware run in order.
- Handler invocation -- the actual tool function is called.
- Result serialisation -- the return value is converted to MCP
TextContentlist. - Background tasks -- any
BackgroundTasksscheduled during the call are executed beforecall_toolreturns, so tests can assert on their effects. (A running server sends the response first and runs them afterwards.) - Error handling --
MCPErrorsubclasses are serialised to structured JSON.
Creating a TestClient¶
from promptise.mcp.server import MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="my-server")
# Basic client (no auth)
client = TestClient(server)
# Client with simulated auth metadata
client = TestClient(server, meta={"authorization": "Bearer my-test-token"})
The meta dict is copied into every RequestContext.meta the client creates,
simulating HTTP request headers without an actual transport.
Calling tools¶
call_tool returns a list[TextContent], exactly like the real MCP server:
from promptise.mcp.server import MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="test")
@server.tool()
async def greet(name: str) -> str:
"""Greet someone."""
return f"Hello, {name}!"
async def test_greet():
client = TestClient(server)
result = await client.call_tool("greet", {"name": "World"})
assert len(result) == 1
assert result[0].text == "Hello, World!"
Handling errors¶
When a tool is not found, or an exception occurs, the client returns structured error JSON rather than raising -- matching the real server's behaviour:
import json
async def test_unknown_tool():
client = TestClient(server)
result = await client.call_tool("nonexistent", {})
error = json.loads(result[0].text)
assert error["error"]["code"] == "TOOL_NOT_FOUND"
async def test_internal_error():
@server.tool()
async def fail() -> str:
raise ValueError("something broke")
client = TestClient(server)
result = await client.call_tool("fail", {})
error = json.loads(result[0].text)
assert error["error"]["code"] == "INTERNAL_ERROR"
MCPError subclasses (like ToolError, AuthenticationError) are serialised
using their own to_text() method, preserving the error code and retryable
flag.
Testing with authentication¶
Pass a meta dict (the request headers) to simulate authenticated requests:
import pytest
from promptise.mcp.server import AuthMiddleware, JWTAuth, MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="secure")
jwt_auth = JWTAuth(secret="test-secret-at-least-32-bytes-long") # HS256
server.add_middleware(AuthMiddleware(jwt_auth))
@server.tool(auth=True)
async def secret_data() -> str:
"""Return sensitive data."""
return "top-secret-info"
@server.tool(roles=["admin"]) # roles= implies auth=True
async def admin_report() -> str:
"""Admins only."""
return "admin-report"
@pytest.mark.asyncio
async def test_unauthenticated():
client = TestClient(server)
result = await client.call_tool("secret_data", {})
assert "AUTHENTICATION_ERROR" in result[0].text
@pytest.mark.asyncio
async def test_authenticated():
# create_token signs with the server's secret and sets "exp",
# which JWTAuth requires on every token.
token = jwt_auth.create_token({"sub": "test-user"}, expires_in=300)
client = TestClient(server, meta={"authorization": f"Bearer {token}"})
result = await client.call_tool("secret_data", {})
assert result[0].text == "top-secret-info"
@pytest.mark.asyncio
async def test_wrong_role():
token = jwt_auth.create_token({"sub": "viewer", "roles": ["viewer"]}, expires_in=300)
client = TestClient(server, meta={"authorization": f"Bearer {token}"})
result = await client.call_tool("admin_report", {})
assert "ACCESS_DENIED" in result[0].text
AuthMiddleware authenticates only tools that need it: auth=True,
roles=[...], a built-in identity guard such as guards=[RequireAuth()] or
guards=[HasScope("write")], or every tool on MCPServer(require_auth=True).
A token made by hand (for example with PyJWT) must carry an exp claim, or
JWTAuth rejects it.
Testing with middleware¶
Middleware runs in the same order as on the real server:
from promptise.mcp.server import MCPServer, LoggingMiddleware, TimeoutMiddleware
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="test")
server.add_middleware(LoggingMiddleware())
server.add_middleware(TimeoutMiddleware(default_timeout=5.0))
@server.tool()
async def slow_task() -> str:
import asyncio
await asyncio.sleep(0.1)
return "done"
async def test_with_middleware():
client = TestClient(server)
result = await client.call_tool("slow_task", {})
assert result[0].text == "done"
Listing tools¶
Retrieve all registered tools as MCP Tool objects:
async def test_list_tools():
client = TestClient(server)
tools = await client.list_tools()
names = [t.name for t in tools]
assert "add" in names
assert "greet" in names
Reading resources¶
Test static resources and URI templates. Reads run the full pipeline (middleware, authentication, guards, conversion of template parameters), as on the live server:
from promptise.mcp.server import MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="test")
@server.resource("config://app")
async def app_config() -> dict:
return {"version": "1.0"}
@server.resource_template("users://{user_id}/profile")
async def user_profile(user_id: int) -> dict:
return {"user_id": user_id}
async def test_resources():
client = TestClient(server)
# Static resource: a dict arrives as JSON text
text = await client.read_resource("config://app")
assert json.loads(text) == {"version": "1.0"}
# URI template: "42" is converted to the int the handler declares
text = await client.read_resource("users://42/profile")
assert json.loads(text) == {"user_id": 42}
# The MIME type and raw MCP contents, as a client receives them
(item,) = await client.read_resource_contents("config://app")
assert item.mime_type == "application/json" # item.mimeType with the mcp 1.x SDK
# List resources (includes the auto-registered docs://manifest)
resources = await client.list_resources()
assert {str(r.uri) for r in resources} == {"config://app", "docs://manifest"}
# List templates
templates = await client.list_resource_templates()
assert len(templates) == 1
read_resource() returns the text of the first content item, or bytes for a binary resource.
Testing prompts¶
Test registered prompt templates:
from promptise.mcp.server import MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="test")
@server.prompt()
async def summarize(text: str, style: str = "concise") -> str:
return f"Summarize the following text in a {style} style:\n\n{text}"
async def test_prompt():
client = TestClient(server)
result = await client.get_prompt("summarize", {"text": "Hello world"})
assert "Hello world" in result.messages[0].content.text
# List all prompts
prompts = await client.list_prompts()
assert any(p.name == "summarize" for p in prompts)
Testing access to resources and prompts¶
Pass credentials with meta= (or per call with headers=). A denied read or prompt request raises the error the live server sends back as an MCP error:
from promptise.mcp.server import APIKeyAuth, AuthenticationError, AuthMiddleware
server = MCPServer(name="test")
server.add_middleware(AuthMiddleware(APIKeyAuth(keys={
"sk-admin": {"client_id": "alice", "roles": ["admin"]},
"sk-user": {"client_id": "bob", "roles": ["user"]},
})))
@server.resource("hr://payroll", roles=["admin"])
async def payroll() -> dict:
return {"alice": 100}
async def test_payroll_needs_admin():
with pytest.raises(AuthenticationError):
await TestClient(server, meta={"x-api-key": "sk-user"}).read_resource("hr://payroll")
admin = TestClient(server, meta={"x-api-key": "sk-admin"})
assert json.loads(await admin.read_resource("hr://payroll")) == {"alice": 100}
API summary¶
TestClient¶
| Parameter | Type | Default | Description |
|---|---|---|---|
server |
MCPServer |
required | The server instance to test |
meta |
dict[str, Any] \| None |
None |
Simulated request metadata (e.g. auth headers) |
Methods¶
| Method | Returns | Description |
|---|---|---|
call_tool(name, arguments) |
list[TextContent] |
Call a tool through the full pipeline |
list_tools() |
list[Tool] |
List all registered tools |
read_resource(uri, *, headers=None) |
str \| bytes |
Read a resource through the full pipeline |
read_resource_contents(uri, *, headers=None) |
list[TextResourceContents \| BlobResourceContents] |
Read a resource and return the MCP contents (with mimeType) |
list_resources() |
list[Resource] |
List all static resources, including docs://manifest |
list_resource_templates() |
list[ResourceTemplate] |
List all resource URI templates |
get_prompt(name, arguments, *, headers=None) |
GetPromptResult |
Get a prompt through the full pipeline |
list_prompts() |
list[Prompt] |
List all registered prompts |
Tips and gotchas¶
Tip
TestClient is marked with __test__ = False so pytest does not try to
collect it as a test class. Import it normally from
promptise.mcp.server.testing.
Warning
Errors from MCPError subclasses are serialised to JSON text (not raised).
If you expect a tool call to fail, parse result[0].text as JSON and check
the error.code field.
Tip
The meta dict in TestClient merges with any ambient HTTP request
headers (from context variables). Explicit meta keys take precedence,
so test code always wins over ambient state.
Warning
call_tool returns list[TextContent]. A successful call with a None
return value from the handler produces [TextContent(text="OK")]. A
dict or list return value is JSON-serialised. A str is returned
as-is.
Tip
Combine TestClient with pytest fixtures for clean, reusable test setups:
import pytest
from promptise.mcp.server.testing import TestClient
@pytest.fixture
def client():
return TestClient(server, meta={"authorization": "Bearer test-token"})
@pytest.mark.asyncio
async def test_tool(client):
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result[0].text == "3"
What's next¶
- Building Servers — create the server you are testing
- Auth & Security — set up authentication for guard testing
- Routers & Middleware — modular routing that TestClient exercises
- Caching & Performance — test caching and rate limiting behavior
- Deployment — deploy your tested server to production
- MCP Client — connect to a real running server
Learn the concept
- Testing MCP Servers in Python: pytest Without a Live Server -- Test a server's auth, guards and job queue with pytest, plus CI.