0
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の引数を4つのガードで弾くFastMCP完全実装

0
Posted at

夜間のタスク整理エージェントが、ある朝から検索結果を1件も返さなくなりました。原因特定に3日かかった私の失敗です。ログを見ると、MCPツールに渡っていたのは due_from: "今日"。AIはschemaを読まず、相対日付をそのまま送ってきました。関数の中で日付を解釈する設計だったためValueErrorを握りつぶして空のリストが返り、AIはそれを成功と思い同じ引数でリトライし続けていました。

この記事では、AIが渡す壊れた引数を「呼び出しの前」と「関数の中」の2段階で弾く4つのガードを判断軸つきの表で示し、コピペで動くFastMCPサーバー1本と動作確認クライアント1本をそのまま載せます。

先に結論: 4つのガードの判断軸

ガード 何を弾くか どこに書く AIへの伝わり方
1. 型のガード 型違い・選択肢外の値(due_from: "今日") 引数の型ヒント(Literal / date) schemaとして呼び出し前に見える
2. 範囲のガード 極端な数値(limit=999) Field(ge=, le=) schemaの最小値・最大値
3. 意味のガード schemaに書けない業務ルール(期間の逆転) 関数の先頭の早期return 構造化エラー
4. 回復のガード 「どう直せばいいか」が不明な失敗 戻り値の error.hint AIが読んで引数を自己修正

順番が重要です。ガード1〜2は関数が呼ばれる前に効く(schemaがAIに渡るため)。ガード3はschemaで表現できないルールの受け皿で、ガード4はそこで弾いたときにAIが自分で直せる形で返す仕組みです。この4枚を下から(4→1)ではなく上から(1→4)積むことで、AIはそもそも壊れた引数を送らなくなり、送っても弾かれ、弾かれた理由から自分で直せます。

失敗した設計: 引数をdictで受けて、例外を握りつぶす

私が最初に書いていたのはこれです(@mcp.tool() を付けてFastMCPに生やしていました)。この関数は正しく動くこともあり、たまに壊れるので一番始末が悪い設計でした。

# 失敗した設計(実際に書いていた search_tasks の再現)
from datetime import datetime

_TASKS = [
    {"id": "T-001", "status": "todo", "due": "2026-11-05"},
    {"id": "T-003", "status": "todo", "due": "2026-11-20"},
]


def search_tasks_bad(args: dict) -> list:
    """タスクを検索する"""
    try:
        due_from = datetime.strptime(args.get("due_from", ""), "%Y-%m-%d").date()
        return [t for t in _TASKS
                if t["status"] == args.get("status")
                and t["due"] >= due_from.isoformat()]
    except Exception:
        return []   # ← 「今日」はValueError。だが空リストはAIに「成功」と読まれる

壊れる理由は3つあります。

  1. schemaが「何でも受け取る」になる — 引数が dict のため、AIに渡るschemaは {"args": {"type": "object"}}。AIは「due_from に日付を入れる」「そもそもargsで包む」を知りようがなく、{"args": {"due_from": "今日"}} も "今日" も平気で送ってくる。
  2. 握りつぶした例外が「成功」に化ける — return [] はエラーを表さない。AIは「該当0件」という正常な結果として受け取り、引数が悪いことに気づけない。だから同じ引数で何度でもリトライする。
  3. エラーが構造化されていない — 仮に raise したとしても、AIに返るのは英語のトレースバック。どこをどう直せばいいかは伝わらず、自己修正は運任せになる。

完全実装: 4つのガードを積んだ tasks_server.py

Python 3.10以上で、pip install "mcp[cli]" だけです。Claude Codeへの登録は claude mcp add tasks -- python tasks_server.py 、動作確認は後述の client_test.py で行えます。

# tasks_server.py — AIの引数を4つのガードで弾くタスク検索MCPサーバー
from datetime import date
from typing import Annotated, Literal, Optional

from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field

mcp = FastMCP("tasks")

# ---- デモデータ(本来はDBやスプレッドシートから読む) ----
_RAW = [
    ("T-001", "請求書の仕訳", "todo", "2026-11-05"),
    ("T-002", "月次レポート", "doing", "2026-11-10"),
    ("T-003", "タグ整理", "todo", "2026-11-20"),
    ("T-004", "バッチ改修", "done", "2026-10-31"),
]


class Task(BaseModel):
    id: str
    title: str
    status: Literal["todo", "doing", "done"]
    due: date


_TASKS = [Task(id=i, title=t, status=s, due=date.fromisoformat(d))
          for i, t, s, d in _RAW]


def _ok(tasks: list[Task]) -> dict:
    return {"ok": True, "count": len(tasks),
            "tasks": [t.model_dump(mode="json") for t in tasks]}


def _error(code: str, message: str, hint: str) -> dict:
    # ガード4: AIが読んで自己修正できる構造化エラー(「空リスト」を返さない)
    return {"ok": False, "count": 0, "tasks": [],
            "error": {"code": code, "message": message, "hint": hint}}


@mcp.tool()
def search_tasks(
    status: Literal["todo", "doing", "done"],           # ガード1: 選択肢はschemaで閉じる
    due_from: date,                                     # ガード1: 「今日」は型で弾かれる
    due_to: Optional[date] = None,
    limit: Annotated[int, Field(ge=1, le=100)] = 20,    # ガード2: 範囲をschemaで宣言
) -> dict:
    """未処理タスクを期限で検索する。
    status は todo / doing / done のいずれか。
    due_from / due_to は YYYY-MM-DD 形式の日付のみ(「今日」等の相対表現は不可)。
    due_to を指定するときは due_from 以降の日付にすること。
    """
    tasks = [t for t in _TASKS if t.status == status and t.due >= due_from]

    if due_to is not None:
        if due_to < due_from:
            # ガード3: schemaに書けない業務ルールは関数の先頭で早期return
            return _error(
                "INVALID_RANGE",
                f"due_to({due_to.isoformat()}) が due_from({due_from.isoformat()}) より前です。",
                "due_to には due_from と同じか未来の日付を YYYY-MM-DD で指定してください。",
            )
        tasks = [t for t in tasks if t.due <= due_to]

    return _ok(tasks[:limit])


if __name__ == "__main__":
    mcp.run(transport="stdio")

設計のポイントは、「schemaに書けることは型ヒントに、書けないことは早期returnに、失敗の伝え方はhintに」 という3分割です。引数の date と Literal はFastMCPが自動でJSON Schemaに変換するため、AIは呼び出し前に「due_from は YYYY-MM-DD 形式」「status は3択」を知ります。私の失敗の根っこは、このschemaを "今日" でも通る dict にしていたことでした。

なお、ガード3の結果は isError=False のまま ok=false のペイロードとして返す設計です。クライアントによっては isError=True がエージェントのループ自体を止めることがあり、「引数を直せば成功する」種類の失敗は、AIが読める結果として返す方が夜間バッチは安定します。

動作確認: client_test.py で60秒

tasks_server.py と同じフォルダで実行します。

# client_test.py — 4つのガードが効いていることを確かめる
import asyncio

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def call(session, name, args):
    r = await session.call_tool(name, args)
    text = r.content[0].text if r.content else ""
    print(f"  isError={r.isError} / {text[:120]}")


async def main():
    server = StdioServerParameters(command="python", args=["tasks_server.py"])
    async with stdio_client(server) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            print("(1) 正常系: ISO日付は通る")
            await call(session, "search_tasks",
                       {"status": "todo", "due_from": "2026-11-01"})

            print("(2) ガード1: 「今日」はschemaで弾かれる(関数に入らない)")
            await call(session, "search_tasks",
                       {"status": "todo", "due_from": "今日"})

            print("(3) ガード2: limit=999 は範囲で弾かれる")
            await call(session, "search_tasks",
                       {"status": "todo", "due_from": "2026-11-01", "limit": 999})

            print("(4) ガード3+4: 逆順の期間は構造化エラー+hintで返る")
            await call(session, "search_tasks",
                       {"status": "todo", "due_from": "2026-11-30", "due_to": "2026-11-01"})


asyncio.run(main())

実行すると、4つの呼び出しが次のように分かれます。

(1) 正常系: ISO日付は通る
  isError=False / {"ok": true, "count": 2,
(2) ガード1: 「今日」はschemaで弾かれる(関数に入らない)
  isError=True / Error executing tool search_tasks ... due_from: Input should be a valid date ...
(3) ガード2: limit=999 は範囲で弾かれる
  isError=True / Error executing tool search_tasks ... limit: Input should be less than or equal to 100
(4) ガード3+4: 逆順の期間は構造化エラー+hintで返る
  isError=False / {"ok": false, "count": 0, "error": {"code": "INVALID_RANGE", "message": "due_to(2026-11-01) が due_from(2026-11-30) より前です。", "hint": "due_to には due_from と同じか未来の日付を YYYY-MM-DD で指定してください。"}}

(2)(3) はFastMCPのバリデーションが関数の前で弾いたものです。AIにはこのエラーが返るため、次の呼び出しでは自力で形式を直します。(4) はschemaでは表現できないルールなので、関数の中の早期returnで hint つきの構造化エラーを返しています。ここで大事なのは「弾いただけで終わらない」ことです。hintに「正しい形」を書くことで、AIは1回で自己修正できます。

エラーの返し方3方式の比較

方式 AIの挙動 実害
例外を握りつぶして空を返す(失敗した設計) 「0件」を成功と解釈し、同じ引数でリトライを続ける 無限ループ・静かな沈黙(私の事故)
例外をそのままraise isErrorで止まる。理由は英語のメッセージのみ 自己修正は運任せ。ループが止まることも
構造化エラー+hintを返す(本記事の実装) hintを読んで引数を直して再送する 1回で自己修正。夜間バッチが止まらない

まとめ: 次にやること

  1. 自分のMCPサーバーで「引数が dict・str だけで受けているツール」を探す——その数だけ、AIに壊れた引数を送る自由を与えています
  2. 選択肢は Literal、日付は date、数値の範囲は Field(ge=, le=) に置き換えて、schemaでガードする
  3. schemaに書けない業務ルールは関数の先頭で早期returnし、code / message / hint の構造化エラーで「直し方」まで返す

この記事の実装チェックリスト(保存用)

  • 引数を素の dict で受けるツールが無い(schemaが「何でも」になっている)
  • 選択肢は Literal、日付は date で型ヒントを付けた(ガード1)
  • 数値には Field(ge=, le=) の範囲を付けた(ガード2)
  • schemaに書けない業務ルールを関数の先頭で検証している(ガード3)
  • 弾いたときは code / message / hint の構造化エラーで返す(ガード4)
  • except で空リストや None を握りつぶして返していない
  • client_test.py 相当の「壊れた引数を送るテスト」を一度は実行した
  • ツールの説明文(docstring)に「不可な形式」も書いた

読者特典(無料)

本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。

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

関連記事:

参考書籍: MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

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

0
0
0

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
0
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?