Skip to content

Commit 814c9c0

Browse files
authored
Add documentation about testing (#1426)
1 parent 89619a8 commit 814c9c0

File tree

4 files changed

+92
-13
lines changed

4 files changed

+92
-13
lines changed

docs/testing.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Testing MCP Servers
2+
3+
If you call yourself a developer, you will want to test your MCP server.
4+
The Python SDK offers the `create_connected_server_and_client_session` function to create a session
5+
using an in-memory transport. I know, I know, the name is too long... We are working on improving it.
6+
7+
Anyway, let's assume you have a simple server with a single tool:
8+
9+
```python title="server.py"
10+
from mcp.server import FastMCP
11+
12+
app = FastMCP("Calculator")
13+
14+
@app.tool()
15+
def add(a: int, b: int) -> int:
16+
"""Add two numbers.""" # (1)!
17+
return a + b
18+
```
19+
20+
1. The docstring is automatically added as the description of the tool.
21+
22+
To run the below test, you'll need to install the following dependencies:
23+
24+
=== "pip"
25+
```bash
26+
pip install inline-snapshot pytest
27+
```
28+
29+
=== "uv"
30+
```bash
31+
uv add inline-snapshot pytest
32+
```
33+
34+
!!! info
35+
I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework,
36+
so I won't go into details here.
37+
38+
The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows
39+
you to take snapshots of the output of your tests. Which makes it easier to create tests for your
40+
server - you don't need to use it, but we are spreading the word for best practices.
41+
42+
```python title="test_server.py"
43+
from collections.abc import AsyncGenerator
44+
45+
import pytest
46+
from inline_snapshot import snapshot
47+
from mcp.client.session import ClientSession
48+
from mcp.shared.memory import create_connected_server_and_client_session
49+
from mcp.types import CallToolResult, TextContent
50+
51+
from server import app
52+
53+
54+
@pytest.fixture
55+
def anyio_backend(): # (1)!
56+
return "asyncio"
57+
58+
59+
@pytest.fixture
60+
async def client_session() -> AsyncGenerator[ClientSession]:
61+
async with create_connected_server_and_client_session(app, raise_exceptions=True) as _session:
62+
yield _session
63+
64+
65+
@pytest.mark.anyio
66+
async def test_call_add_tool(client_session: ClientSession):
67+
result = await client_session.call_tool("add", {"a": 1, "b": 2})
68+
assert result == snapshot(
69+
CallToolResult(
70+
content=[TextContent(type="text", text="3")],
71+
structuredContent={"result": 3},
72+
)
73+
)
74+
```
75+
76+
1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on).
77+
78+
There you go! You can now extend your tests to cover more scenarios.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ nav:
1717
- Concepts: concepts.md
1818
- Low-Level Server: low-level-server.md
1919
- Authorization: authorization.md
20+
- Testing: testing.md
2021
- API Reference: api.md
2122

2223
theme:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,5 @@ MD013=false # line-length - Line length
165165
MD029=false # ol-prefix - Ordered list item prefix
166166
MD033=false # no-inline-html Inline HTML
167167
MD041=false # first-line-heading/first-line-h1
168+
MD046=false # indented-code-blocks
168169
MD059=false # descriptive-link-text

src/mcp/shared/memory.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
In-memory transports
33
"""
44

5+
from __future__ import annotations
6+
57
from collections.abc import AsyncGenerator
68
from contextlib import asynccontextmanager
79
from datetime import timedelta
@@ -11,15 +13,9 @@
1113
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
1214

1315
import mcp.types as types
14-
from mcp.client.session import (
15-
ClientSession,
16-
ElicitationFnT,
17-
ListRootsFnT,
18-
LoggingFnT,
19-
MessageHandlerFnT,
20-
SamplingFnT,
21-
)
16+
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
2217
from mcp.server import Server
18+
from mcp.server.fastmcp import FastMCP
2319
from mcp.shared.message import SessionMessage
2420

2521
MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
@@ -52,7 +48,7 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS
5248

5349
@asynccontextmanager
5450
async def create_connected_server_and_client_session(
55-
server: Server[Any],
51+
server: Server[Any] | FastMCP,
5652
read_timeout_seconds: timedelta | None = None,
5753
sampling_callback: SamplingFnT | None = None,
5854
list_roots_callback: ListRootsFnT | None = None,
@@ -63,10 +59,13 @@ async def create_connected_server_and_client_session(
6359
elicitation_callback: ElicitationFnT | None = None,
6460
) -> AsyncGenerator[ClientSession, None]:
6561
"""Creates a ClientSession that is connected to a running MCP server."""
66-
async with create_client_server_memory_streams() as (
67-
client_streams,
68-
server_streams,
69-
):
62+
63+
# TODO(Marcelo): we should have a proper `Client` that can use this "in-memory transport",
64+
# and we should expose a method in the `FastMCP` so we don't access a private attribute.
65+
if isinstance(server, FastMCP):
66+
server = server._mcp_server # type: ignore[reportPrivateUsage]
67+
68+
async with create_client_server_memory_streams() as (client_streams, server_streams):
7069
client_read, client_write = client_streams
7170
server_read, server_write = server_streams
7271

0 commit comments

Comments
 (0)