夜間のタスク整理エージェントが、ある朝から検索結果を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つあります。
-
schemaが「何でも受け取る」になる — 引数が
dictのため、AIに渡るschemaは{"args": {"type": "object"}}。AIは「due_from に日付を入れる」「そもそもargsで包む」を知りようがなく、{"args": {"due_from": "今日"}}も"今日"も平気で送ってくる。 -
握りつぶした例外が「成功」に化ける —
return []はエラーを表さない。AIは「該当0件」という正常な結果として受け取り、引数が悪いことに気づけない。だから同じ引数で何度でもリトライする。 -
エラーが構造化されていない — 仮に
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回で自己修正。夜間バッチが止まらない |
まとめ: 次にやること
- 自分のMCPサーバーで「引数が
dict・strだけで受けているツール」を探す——その数だけ、AIに壊れた引数を送る自由を与えています - 選択肢は
Literal、日付はdate、数値の範囲はField(ge=, le=)に置き換えて、schemaでガードする - 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サーバーにツールを11個渡したらAIが4割使い間違えた — 道具が選ばれるdescription設計の5つの型
- MCPサーバーのエラーハンドリング — AIが自力で復帰する5つの設計パターン
- 自作MCPサーバーに「読み取り専用モード」を実装する — AIに何をさせないかを設計する
参考書籍: MCP実践入門 — AIエージェントを拡張するModel Context Protocol(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)