Add documentation about testing (#1426)
This commit is contained in:
parent
89619a8604
commit
814c9c024a
4 changed files with 92 additions and 13 deletions
78
docs/testing.md
Normal file
78
docs/testing.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Testing MCP Servers
|
||||||
|
|
||||||
|
If you call yourself a developer, you will want to test your MCP server.
|
||||||
|
The Python SDK offers the `create_connected_server_and_client_session` function to create a session
|
||||||
|
using an in-memory transport. I know, I know, the name is too long... We are working on improving it.
|
||||||
|
|
||||||
|
Anyway, let's assume you have a simple server with a single tool:
|
||||||
|
|
||||||
|
```python title="server.py"
|
||||||
|
from mcp.server import FastMCP
|
||||||
|
|
||||||
|
app = FastMCP("Calculator")
|
||||||
|
|
||||||
|
@app.tool()
|
||||||
|
def add(a: int, b: int) -> int:
|
||||||
|
"""Add two numbers.""" # (1)!
|
||||||
|
return a + b
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The docstring is automatically added as the description of the tool.
|
||||||
|
|
||||||
|
To run the below test, you'll need to install the following dependencies:
|
||||||
|
|
||||||
|
=== "pip"
|
||||||
|
```bash
|
||||||
|
pip install inline-snapshot pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "uv"
|
||||||
|
```bash
|
||||||
|
uv add inline-snapshot pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework,
|
||||||
|
so I won't go into details here.
|
||||||
|
|
||||||
|
The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows
|
||||||
|
you to take snapshots of the output of your tests. Which makes it easier to create tests for your
|
||||||
|
server - you don't need to use it, but we are spreading the word for best practices.
|
||||||
|
|
||||||
|
```python title="test_server.py"
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from inline_snapshot import snapshot
|
||||||
|
from mcp.client.session import ClientSession
|
||||||
|
from mcp.shared.memory import create_connected_server_and_client_session
|
||||||
|
from mcp.types import CallToolResult, TextContent
|
||||||
|
|
||||||
|
from server import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anyio_backend(): # (1)!
|
||||||
|
return "asyncio"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_session() -> AsyncGenerator[ClientSession]:
|
||||||
|
async with create_connected_server_and_client_session(app, raise_exceptions=True) as _session:
|
||||||
|
yield _session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_call_add_tool(client_session: ClientSession):
|
||||||
|
result = await client_session.call_tool("add", {"a": 1, "b": 2})
|
||||||
|
assert result == snapshot(
|
||||||
|
CallToolResult(
|
||||||
|
content=[TextContent(type="text", text="3")],
|
||||||
|
structuredContent={"result": 3},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
There you go! You can now extend your tests to cover more scenarios.
|
||||||
|
|
@ -17,6 +17,7 @@ nav:
|
||||||
- Concepts: concepts.md
|
- Concepts: concepts.md
|
||||||
- Low-Level Server: low-level-server.md
|
- Low-Level Server: low-level-server.md
|
||||||
- Authorization: authorization.md
|
- Authorization: authorization.md
|
||||||
|
- Testing: testing.md
|
||||||
- API Reference: api.md
|
- API Reference: api.md
|
||||||
|
|
||||||
theme:
|
theme:
|
||||||
|
|
|
||||||
|
|
@ -165,4 +165,5 @@ MD013=false # line-length - Line length
|
||||||
MD029=false # ol-prefix - Ordered list item prefix
|
MD029=false # ol-prefix - Ordered list item prefix
|
||||||
MD033=false # no-inline-html Inline HTML
|
MD033=false # no-inline-html Inline HTML
|
||||||
MD041=false # first-line-heading/first-line-h1
|
MD041=false # first-line-heading/first-line-h1
|
||||||
|
MD046=false # indented-code-blocks
|
||||||
MD059=false # descriptive-link-text
|
MD059=false # descriptive-link-text
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
In-memory transports
|
In-memory transports
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
@ -11,15 +13,9 @@ import anyio
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
|
|
||||||
import mcp.types as types
|
import mcp.types as types
|
||||||
from mcp.client.session import (
|
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
||||||
ClientSession,
|
|
||||||
ElicitationFnT,
|
|
||||||
ListRootsFnT,
|
|
||||||
LoggingFnT,
|
|
||||||
MessageHandlerFnT,
|
|
||||||
SamplingFnT,
|
|
||||||
)
|
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
from mcp.shared.message import SessionMessage
|
from mcp.shared.message import SessionMessage
|
||||||
|
|
||||||
MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
|
MessageStream = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
|
||||||
|
|
@ -52,7 +48,7 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def create_connected_server_and_client_session(
|
async def create_connected_server_and_client_session(
|
||||||
server: Server[Any],
|
server: Server[Any] | FastMCP,
|
||||||
read_timeout_seconds: timedelta | None = None,
|
read_timeout_seconds: timedelta | None = None,
|
||||||
sampling_callback: SamplingFnT | None = None,
|
sampling_callback: SamplingFnT | None = None,
|
||||||
list_roots_callback: ListRootsFnT | None = None,
|
list_roots_callback: ListRootsFnT | None = None,
|
||||||
|
|
@ -63,10 +59,13 @@ async def create_connected_server_and_client_session(
|
||||||
elicitation_callback: ElicitationFnT | None = None,
|
elicitation_callback: ElicitationFnT | None = None,
|
||||||
) -> AsyncGenerator[ClientSession, None]:
|
) -> AsyncGenerator[ClientSession, None]:
|
||||||
"""Creates a ClientSession that is connected to a running MCP server."""
|
"""Creates a ClientSession that is connected to a running MCP server."""
|
||||||
async with create_client_server_memory_streams() as (
|
|
||||||
client_streams,
|
# TODO(Marcelo): we should have a proper `Client` that can use this "in-memory transport",
|
||||||
server_streams,
|
# and we should expose a method in the `FastMCP` so we don't access a private attribute.
|
||||||
):
|
if isinstance(server, FastMCP):
|
||||||
|
server = server._mcp_server # type: ignore[reportPrivateUsage]
|
||||||
|
|
||||||
|
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||||
client_read, client_write = client_streams
|
client_read, client_write = client_streams
|
||||||
server_read, server_write = server_streams
|
server_read, server_write = server_streams
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue