1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPサーバーのテストを書く — AIが呼ぶ「道具」をどう検証するか、モックの作り方3段階

1
Posted at

はじめに

MCPサーバーを書いて、Claudeに繋いで、動いた。でもテストが1つもありません。

これは私の実際の状態でした。理由は単純で、「AIが呼ぶ道具」のテストの書き方が分からなかったからです。HTTPのAPIと違って、リクエストはClaudeが組み立てます。何が来るか分からないものをどう検証するのか。

結論から言うと、MCPサーバーのテストは「プロトコル層」「ツール層」「統合層」の3段階に分けると書けます。この記事では、実際に書いたテストと、最初に失敗した設計を書きます。

なぜテストが必要か(実害)

私はMCPサーバーで引数の型を間違えて受け取るバグを出しました。Claudeは {"count": "5"} のように文字列で数値を送ってくることがあります。int を期待していたので、ツールが例外を投げ、Claudeは「ツールが失敗しました」とだけ返しました。

人間にはエラーの内容が見えません。 この種のバグはテストがなければ永遠に見つかりません。

3段階のテスト設計

① プロトコル層: JSON-RPCのやり取りが仕様どおりか
② ツール層:    個々のツールの入出力が正しいか(← ここが一番おいしい)
③ 統合層:      実際にAIから呼んで意図どおり動くか

投資対効果が最も高いのは②です。 ①はSDKが担保してくれます。③は自動化が難しい。②を厚くするのが正解です。

① プロトコル層:initializeとtools/list

SDKを使えば最小限で済みます。サーバーが起動して、ツール一覧を返せることだけ確認します。

import pytest
from mcp_server import server  # 自作サーバーのモジュール

def test_initialize():
    """初期化ハンドシェイクが通る"""
    resp = server.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize",
                          "params": {"protocolVersion": "2024-11-05"}})
    assert resp["result"]["protocolVersion"] is not None

def test_tools_list_not_empty():
    """ツールが1つ以上公開されている"""
    resp = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
    tools = resp["result"]["tools"]
    assert len(tools) > 0
    # 説明文が空のツールはAIが迷うため、必須にする
    for t in tools:
        assert t["description"].strip(), f"{t['name']} の説明が空です"

最後のアサーションが地味に効きます。 説明文が空のツールは、AIが使ってくれません。テストで強制すると書き忘れが消えます。

② ツール層:入出力を直接検証する(最重要)

ツール関数をプロトコル層から切り離してテストします。これができるように設計することが大事です。

from mcp_server.tools import search_notes, SearchNotesInput

def test_search_notes_returns_matches():
    """キーワードに一致するノートを返す"""
    out = search_notes(SearchNotesInput(keyword="MCP", limit=5))
    assert isinstance(out, list)
    for item in out:
        assert "MCP" in item["title"] or "MCP" in item["body"]

def test_search_notes_zero_hits_is_not_error():
    """0件はエラーではなく空リストを返す(AIが再試行できるように)"""
    out = search_notes(SearchNotesInput(keyword="存在しないキーワードxyz", limit=5))
    assert out == []          # 例外を投げない

型のゆれを吸収する(実際に踏んだバグ)

ここが最初に失敗した設計です。私は型を厳密に検証し、str で来たらエラーにしていました。

# ❌ 失敗した設計:型が違うと即エラー
def old_tool(count: int):
    if not isinstance(count, int):
        raise ValueError("count must be int")   # Claudeは "5" を送ることがある

AIは型を厳密に守りません。 正解は「受け入れて正規化する」です。

from pydantic import BaseModel, field_validator

class SearchInput(BaseModel):
    keyword: str
    limit: int = 5

    @field_validator("limit", mode="before")
    @classmethod
    def coerce_limit(cls, v):
        """"5" のような文字列も受け入れる(AIは型を守らない)"""
        try:
            return int(v)
        except (TypeError, ValueError):
            return 5   # 不正値は既定値へ倒す(エラーにしない)

def test_limit_is_coerced_from_string():
    assert SearchInput(keyword="x", limit="5").limit == 5

def test_limit_falls_back_on_garbage():
    assert SearchInput(keyword="x", limit="abc").limit == 5

「AIからの入力は信用せず、ただし拒否もしない」 がMCPツールの正しい姿勢です。

③ 統合層:境界値とエラー時の応答

def test_tool_error_is_reported_to_ai():
    """ツールが失敗したとき、AIが理解できる形で返る"""
    result = call_tool("search_notes", {"keyword": None})
    # 例外が外に漏れず、isError フラグで返る
    assert "isError" in result or "content" in result

def test_large_result_is_truncated():
    """巨大な結果を返さない(コンテキストを食い潰さない)"""
    out = search_notes(SearchInput(keyword="", limit=10000))
    assert len(str(out)) < 100_000

「AIに返すエラーは、AIが読める形にする」 のが重要です。スタックトレースをそのまま返しても、AIは復帰できません。

失敗パターン3つ

  1. プロトコル層のテストだけ書いた — ハンドシェイクは通るのに、ツールの中身が壊れていた。ツール層を厚くするのが正解。
  2. 型を厳密に検証してエラーにした — AIは "5" を送ってくる。正規化して受け入れるべきだった。
  3. LLMを実際に呼ぶ統合テストを書いた — 実行が遅く、結果が不安定で、CIで回せなかった。LLM呼び出しはモックする。
# LLM呼び出しはモックして、テストを決定的にする
from unittest.mock import patch

@patch("mcp_server.agent.call_llm")
def test_tool_selection_with_mock(mock_llm):
    mock_llm.return_value = {"tool": "search_notes", "args": {"keyword": "MCP"}}
    result = run_agent("MCPについて調べて")
    assert "search_notes" in str(result)

テスト層の比較

層 書くコスト 得られる安心 CIで回せる 優先度
① プロトコル 低 低(SDKが担保) ○ 低
② ツール 中 高(バグの大半はここ) ○ 最優先
③ 統合(モック) 中 中 ○ 中
③ 統合(実LLM) 高 低(不安定) × 最小限

②に7割の時間を使ってください。 私の経験では、MCPサーバーのバグの大半はツール層で見つかります。

まとめ

  • テストは3層に分ける。②ツール層に投資する
  • AIからの入力は型を守らない。正規化して受け入れ、拒否しない
  • 0件はエラーではない。空の結果を返してAIに再試行させる
  • LLM呼び出しはモックする。実LLMテストはCIで回せない
  • 説明文が空のツールはAIが使わない。テストで強制する

AIが相手のテストは「正しさの証明」より「壊れたときにAIが復帰できるか」を見るものです。そこが普通のAPIテストと違う点です。


📗 MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・読み放題対象)

🎁 読者特典を受け取る(無料・メール登録)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?