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.
- 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. - 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 to simulate authenticated requests:
from promptise.mcp.server import MCPServer, AuthMiddleware, JWTAuth, RequireAuth
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="secure")
jwt_auth = JWTAuth(secret="test-secret", algorithm="HS256")
server.add_middleware(AuthMiddleware(jwt_auth))
@server.tool(guards=[RequireAuth()])
async def secret_data() -> str:
"""Return sensitive data."""
return "top-secret-info"
async def test_unauthenticated():
client = TestClient(server)
result = await client.call_tool("secret_data", {})
error_text = result[0].text
assert "ACCESS_DENIED" in error_text
async def test_authenticated():
# Generate a test token
import jwt as pyjwt
token = pyjwt.encode({"sub": "test-user"}, "test-secret", algorithm="HS256")
client = TestClient(server, meta={"authorization": f"Bearer {token}"})
result = await client.call_tool("secret_data", {})
assert result[0].text == "top-secret-info"
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:
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() -> str:
return '{"version": "1.0"}'
@server.resource("users://{user_id}/profile")
async def user_profile(user_id: str) -> str:
return f'{{"user_id": "{user_id}"}}'
async def test_resources():
client = TestClient(server)
# Static resource
text = await client.read_resource("config://app")
assert "1.0" in text
# URI template
text = await client.read_resource("users://42/profile")
assert "42" in text
# List resources
resources = await client.list_resources()
assert any(r.uri == "config://app" for r in resources)
# List templates
templates = await client.list_resource_templates()
assert len(templates) > 0
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)
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) |
str |
Read a resource by URI |
list_resources() |
list[Resource] |
List all static resources |
list_resource_templates() |
list[ResourceTemplate] |
List all resource URI templates |
get_prompt(name, arguments) |
GetPromptResult |
Execute a prompt template |
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