FastMCPをpytestでテストする|Client・モック・HTTP

FastMCPサーバーをpytestとClientで自動テストする方法 LLM

FastMCPサーバーを作ったとき、「Python関数を直接呼べば動く」だけでは十分ではありません。

実際のMCPクライアントは、ツール一覧を取得し、JSON Schemaに沿った引数を送り、MCPプロトコル経由で結果やエラーを受け取ります。関数の単体テストだけでは、この境界で起きる問題を見落とします。

FastMCPには、サーバーを同じPythonプロセス内で動かせるインメモリClientがあります。ネットワークや別プロセスを起動せず、実際のMCP処理を高速にテストできるのが特徴です。

この記事では、pytestとFastMCP Clientを使い、ツール、入力エラー、外部APIのモック、Streamable HTTPまで段階的に検証します。

この記事のゴール

  • pytestからFastMCP Clientを使える
  • ツールの登録・実行・異常系を検証できる
  • 外部APIやデータベースをモックできる
  • インメモリテストとHTTPテストを使い分けられる
FastMCPサーバーのインメモリテストとHTTPテストの構成
FastMCPのテスト構成:高速なインメモリテストを中心にし、HTTPと実環境の確認を必要な範囲だけ追加する
スポンサーリンク

FastMCPでは何をテストするべきか

MCPサーバーのテスト対象は、通常のPython関数より少し広くなります。

対象 確認すること
ツール一覧 名前、説明、引数Schemaが意図どおりか
ツール実行 正しい入力で期待する構造が返るか
入力エラー 型違い、必須値不足、未知IDを拒否するか
リソース URIから正しい内容を取得できるか
プロンプト 引数がテンプレートへ正しく反映されるか
外部依存 API障害、タイムアウト、空レスポンスを扱えるか
トランスポート HTTPやSTDIOで実際に接続できるか
認証 無認証や権限不足を拒否するか

最初からすべてを実ネットワークで確認すると、テストが遅く不安定になります。まずインメモリでMCPの振る舞いを検証し、通信経路に固有の部分だけHTTPテストへ分けるのが基本です。

スポンサーリンク

サンプルのFastMCPサーバー

今回は、足し算ツールとユーザー検索ツールを持つサーバーを例にします。

# src/app/server.py
from dataclasses import dataclass
from typing import Protocol

from fastmcp import FastMCP

class UserRepository(Protocol):
    async def find(self, user_id: int) -> dict | None: ...

@dataclass
class InMemoryUserRepository:
    users: dict[int, dict]

    async def find(self, user_id: int) -> dict | None:
        return self.users.get(user_id)

def create_server(repository: UserRepository) -> FastMCP:
    mcp = FastMCP("Example Server")

    @mcp.tool
    def add(x: int, y: int) -> int:
        """2つの整数を加算する。"""
        return x + y

    @mcp.tool
    async def get_user(user_id: int) -> dict:
        """IDからユーザーを取得する。"""
        user = await repository.find(user_id)
        if user is None:
            raise ValueError(f"user not found: {user_id}")
        return user

    return mcp

mcp = create_server(
    InMemoryUserRepository(
        users={1: {"id": 1, "name": "Alice"}},
    )
)

サーバーをグローバルに組み立てるだけでなく、create_server()を用意しているのがポイントです。テストごとに依存関係とサーバーを作り直せるため、状態が別テストへ漏れにくくなります。

pytest環境を準備する

テスト用パッケージを追加します。

pip install pytest pytest-asyncio pytest-cov

uvを使う場合は次のように追加できます。

uv add --dev pytest pytest-asyncio pytest-cov

pyproject.tomlで非同期テストを自動認識させます。

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
  "integration: HTTPや外部サービスを使う結合テスト",
]

[tool.coverage.run]
source = ["src"]
branch = true

asyncio_mode = "auto"を設定すると、各テストへ毎回@pytest.mark.asyncioを書く必要がありません。

インメモリClientのfixtureを作る

FastMCPインスタンスをClientへ直接渡すと、インメモリトランスポートが選択されます。

# tests/conftest.py
from collections.abc import AsyncIterator

import pytest
from fastmcp import Client

from app.server import InMemoryUserRepository, create_server

@pytest.fixture
async def client() -> AsyncIterator[Client]:
    repository = InMemoryUserRepository(
        users={
            1: {"id": 1, "name": "Alice"},
            2: {"id": 2, "name": "Bob"},
        }
    )
    server = create_server(repository)

    async with Client(server) as mcp_client:
        yield mcp_client

この接続はネットワークを使いません。しかし単純にPython関数を呼ぶのではなく、FastMCP Clientから実際のMCP操作を実行します。引数の検証、ツール呼び出し、戻り値の変換を含めて確認できます。

ツール一覧をテストする

まず、公開予定のツールが登録されていることを確認します。

from fastmcp import Client

async def test_list_tools(client: Client) -> None:
    tools = await client.list_tools()
    tool_names = {tool.name for tool in tools}

    assert tool_names == {"add", "get_user"}

ツール数だけを確認すると、別のツールへ置き換わってもテストが通ります。外部へ公開する名前はインターフェースの一部なので、名前の集合を明示したほうが意図が伝わります。

重要なツールでは、説明や引数Schemaも検証します。

async def test_add_tool_schema(client: Client) -> None:
    tools = await client.list_tools()
    add_tool = next(tool for tool in tools if tool.name == "add")

    assert add_tool.description == "2つの整数を加算する。"
    assert set(add_tool.inputSchema["required"]) == {"x", "y"}
    assert add_tool.inputSchema["properties"]["x"]["type"] == "integer"

ツール名やSchemaを変更するとクライアント側が壊れる可能性があります。このテストは意図しない破壊的変更の検出に役立ちます。

ツール実行をパラメータ化する

正常系は、複数の入力をpytest.mark.parametrizeでまとめられます。

import pytest
from fastmcp import Client

@pytest.mark.parametrize(
    ("x", "y", "expected"),
    [
        (1, 2, 3),
        (0, 0, 0),
        (-5, 8, 3),
    ],
)
async def test_add(
    client: Client,
    x: int,
    y: int,
    expected: int,
) -> None:
    result = await client.call_tool(
        "add",
        {"x": x, "y": y},
    )

    assert result.data == expected

result.dataはFastMCPが構造化した値です。文字列化されたcontentだけを比較するより、型と値を自然に検証できます。

異常系と入力検証をテストする

FastMCP Clientのcall_tool()は、ツール実行が失敗すると標準でToolErrorを送出します。

import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError

async def test_get_user_rejects_unknown_id(client: Client) -> None:
    with pytest.raises(ToolError, match="user not found"):
        await client.call_tool(
            "get_user",
            {"user_id": 999},
        )

エラー結果そのものを検証したい場合は、raise_on_error=Falseを指定します。

async def test_get_user_returns_error_result(client: Client) -> None:
    result = await client.call_tool(
        "get_user",
        {"user_id": 999},
        raise_on_error=False,
    )

    assert result.is_error is True
    assert "user not found" in result.content[0].text

型違いや必須値不足も追加しておきます。

@pytest.mark.parametrize(
    "arguments",
    [
        {"x": 1},
        {"x": "one", "y": 2},
        {"x": 1, "y": None},
    ],
)
async def test_add_rejects_invalid_arguments(
    client: Client,
    arguments: dict,
) -> None:
    with pytest.raises(ToolError):
        await client.call_tool("add", arguments)

エラーメッセージ全文へ強く依存すると、ライブラリ更新で壊れやすくなります。利用者に保証したい自分のエラー文だけを部分一致で確認するのがおすすめです。

外部APIやDBをモックする

テストで実際の外部APIへ接続すると、ネットワーク障害、レート制限、データ変更の影響を受けます。通常のPythonテストと同様に、依存先をモックします。

from unittest.mock import AsyncMock

from fastmcp import Client

from app.server import create_server

async def test_get_user_uses_repository() -> None:
    repository = AsyncMock()
    repository.find.return_value = {
        "id": 10,
        "name": "Test User",
    }
    server = create_server(repository)

    async with Client(server) as client:
        result = await client.call_tool(
            "get_user",
            {"user_id": 10},
        )

    assert result.data == {"id": 10, "name": "Test User"}
    repository.find.assert_awaited_once_with(10)

戻り値だけでなく、依存先へ正しい引数で1回だけアクセスしたことも確認しています。

さらに、外部障害を再現します。

async def test_get_user_handles_repository_timeout() -> None:
    repository = AsyncMock()
    repository.find.side_effect = TimeoutError("repository timeout")
    server = create_server(repository)

    async with Client(server) as client:
        with pytest.raises(ToolError, match="repository timeout"):
            await client.call_tool(
                "get_user",
                {"user_id": 10},
            )

本番で起きやすいのは、成功よりもタイムアウト、空レスポンス、認証切れ、429応答です。少なくとも主要ツールでは、これらを自動テストに含めます。

インメモリとHTTPテストを分ける

FastMCPのpytest実行フローとテスト範囲
変更ごとにインメモリテストを実行し、重要な経路だけHTTP結合テストで確認する

インメモリテストは高速ですが、次の問題は検出できません。

  • HTTPルートやポートの設定ミス
  • リバースプロキシやHTTPSの問題
  • Authorizationヘッダーの転送漏れ
  • コンテナの環境変数不足
  • プロセス起動時だけ発生するエラー

通信経路も検証したい場合は、FastMCPのrun_server_asyncでインプロセスHTTPサーバーを起動します。

from collections.abc import AsyncIterator

import pytest
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async

from app.server import InMemoryUserRepository, create_server

@pytest.fixture
async def http_server_url() -> AsyncIterator[str]:
    server = create_server(
        InMemoryUserRepository(
            users={1: {"id": 1, "name": "Alice"}},
        )
    )
    async with run_server_async(server) as url:
        yield url

@pytest.mark.integration
async def test_add_over_http(http_server_url: str) -> None:
    transport = StreamableHttpTransport(http_server_url)

    async with Client(transport) as client:
        result = await client.call_tool(
            "add",
            {"x": 20, "y": 22},
        )

    assert result.data == 42

別プロセスより起動が速く、ブレークポイントも使いやすい方法です。STDIOのプロセス分離そのものを確認したい場合だけ、別プロセスのテストを追加します。

テストの実行方法

普段の開発では、高速なテストをすべて実行します。

pytest -q

HTTP結合テストを除外したい場合は、マーカーで分けます。

pytest -q -m "not integration"

カバレッジも確認できます。

pytest --cov=src --cov-report=term-missing

カバレッジ率だけを目標にすると、意味の薄いテストが増えます。外部送信、更新、削除、購入など影響の大きいツールは、分岐と異常系を優先してください。

CIでは2段階で実行する

CIでは、速度と信頼性を両立するために分けて実行します。

# 例:CIの考え方
unit-tests:
  command: pytest -q -m "not integration"

integration-tests:
  command: pytest -q -m integration

プルリクエストごとにインメモリテストを実行し、HTTPや外部サービスを使うテストは別ジョブにします。外部APIの実テストは、秘密情報を使える保護されたブランチや定期実行へ限定します。

失敗したテストを安易に再試行して緑にすると、フレークの原因を隠します。再試行を入れる場合も、回数と対象を限定し、初回失敗を記録してください。

よくある失敗

Python関数だけを直接呼ぶ

関数のロジックは確認できますが、MCPのSchema生成、引数検証、結果変換を通りません。ツールの中心ロジックは単体テストしつつ、公開インターフェースはClient経由でも確認します。

すべて実HTTPでテストする

遅く、不安定で、失敗原因も増えます。大部分はインメモリ、HTTP固有の経路だけ結合テストにします。

グローバルなサーバー状態を共有する

テスト順序によって結果が変わります。create_server()とfixtureで、テストごとに新しいサーバーを作ります。

モックの戻り値だけを確認する

誤ったIDで外部APIを呼んでもテストが通る可能性があります。assert_awaited_once_with()などで呼び出し条件も検証します。

正常系しか書かない

未知ID、入力不足、タイムアウト、認証エラーを追加します。本番障害の多くは異常系で発生します。

実運用前のチェックリスト

  • □ pytest-asyncioとasyncio_mode = "auto"を設定した
  • □ ClientへFastMCPインスタンスを渡すインメモリテストがある
  • □ 公開するツール名と引数Schemaを検証した
  • □ 正常系をClient経由で呼び出した
  • □ 必須値不足、型違い、未知IDを検証した
  • □ 外部API、DB、時刻、乱数をテストで制御した
  • □ タイムアウトや429応答を再現した
  • □ 各テストが独立したサーバー状態を持つ
  • □ HTTP固有の経路だけintegrationへ分けた
  • □ 認証が必要なツールは無認証・権限不足も確認した
  • □ 削除・公開・購入など影響の大きいツールを重点的に確認した
  • □ CIで高速テストと結合テストを分けた

まとめ

FastMCPサーバーのテストでは、Python関数の結果だけでなく、MCPクライアントから見える振る舞いを確認することが重要です。

FastMCPインスタンスをClientへ直接渡せば、ネットワークなしで実際のMCP処理を高速に検証できます。まずツール一覧、Schema、正常系、異常系をインメモリで固め、外部依存はモックします。

そのうえで、HTTPルート、認証ヘッダー、コンテナ起動など通信経路に固有の部分だけを結合テストへ追加します。この分担にすると、開発中は速く、本番公開前は確実に確認できるテスト構成になります。

FastMCPサーバーの作成から確認したい場合は、PythonでMCPサーバーを作る|FastMCP入門も参考にしてください。

HTTP環境への公開は、FastMCPをDockerで本番デプロイ|HTTPS・ヘルスチェックへつながります。

認証のテストを追加する場合は、FastMCPの認証を実装する|Bearer・OAuthで保護を先に実装すると流れを整理できます。

参考資料

コメント

タイトルとURLをコピーしました