MCP のツール出力をモデルに渡す前に正規化する — PostToolUse フックパターン

MCP のツール出力をモデルに渡す前に正規化する — PostToolUse フックパターン

複数の MCP サーバーから返るデータ形式の不一致を、プロンプトではなく PostToolUse フックで決定論的に解決するパターンを紹介します。タイムスタンプ正規化の実装例とともに、コードで解くべき処理とモデルに任せるべき処理の線引きを解説します。
2026.07.12

はじめに

複数の MCP サーバーを接続した AI エージェントを運用していると、こんな状況に出くわしたことはないでしょうか?

  • CRM の MCP サーバーは Unix エポック(1718409600)でタイムスタンプを返す
  • チケットシステムは ISO 8601(2026-06-15T09:00:00Z
  • レガシーな社内 API は MM/DD/YYYY 形式

3つのツールから返ってくる日付の形式がバラバラで、エージェントの日付処理が一貫しない。「システムプロンプトに正規化ルールを書けばいいのでは?」と考えて指示を追加しても、数%のケースでまだ失敗する——という問題です。

この記事では、この問題に対する確実な解決策として PostToolUse フック による正規化パターンを紹介します。

なぜシステムプロンプトでの正規化は不十分なのか

システムプロンプトに「すべての日付を ISO 8601 に変換してから処理してください」と指示を追加するアプローチには、根本的な限界があります。

アプローチ 成功率 問題点
指示なし ~80% モデルが形式を推測して処理
プロンプトに正規化指示を追加 ~92% LLM の処理は確率的。複雑な入力で失敗しうる
few-shot 例を追加 ~95% 改善するが 100% にはならない。トークン消費も増加
PostToolUse フックで正規化 100% コードによる決定論的変換。失敗しない

LLM は本質的に確率的な処理を行います。「Unix エポックを ISO 8601 に変換する」のようなルールベースの変換は、モデルが得意な仕事ではありません。コードで確実に解ける問題を、わざわざ確率的な推論に任せる理由はないのです。

PostToolUse フックとは

PostToolUse フックは、MCP ツールの実行結果がモデルに渡されるに介入できる仕組みです。処理の流れは以下の通りです:

mcp-post-tool-use-hook-deterministic-normalization-flow

モデルには常に正規化済みのデータだけが渡るため、プロンプトに変換ルールを書く必要がなくなります。

実装例: タイムスタンプの正規化

以下は、3種類の日付形式を ISO 8601 に統一する PostToolUse フックの実装例です。

from datetime import datetime, timezone
import re

def post_tool_use_hook(tool_name: str, tool_output: dict) -> dict:
    """MCP ツール出力のタイムスタンプを ISO 8601 に正規化する"""
    return normalize_timestamps(tool_output)

def normalize_timestamp(value: str) -> str:
    """個別の値を ISO 8601 に変換する"""
    # Unix epoch (例: "1718409600", "1718409600.123")
    if re.fullmatch(r"\d{10}(\.\d+)?", value):
        dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
        return dt.isoformat()

    # MM/DD/YYYY (例: "06/15/2026")
    try:
        dt = datetime.strptime(value, "%m/%d/%Y").replace(tzinfo=timezone.utc)
        return dt.isoformat()
    except ValueError:
        pass

    # ISO 8601 はそのまま通す (例: "2026-06-15T09:00:00Z")
    try:
        datetime.fromisoformat(value.replace("Z", "+00:00"))
        return value
    except ValueError:
        pass

    # 未知の形式はそのまま返す
    return value

def normalize_timestamps(obj):
    """再帰的にツール出力内の全フィールドを走査して正規化する"""
    if isinstance(obj, str):
        return normalize_timestamp(obj)
    if isinstance(obj, dict):
        return {k: normalize_timestamps(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [normalize_timestamps(item) for item in obj]
    return obj

動作例

# 正規化前: 各 MCP サーバーからの生の出力
raw_outputs = {
    "crm": {"customer": "田中太郎", "last_contact": "1718409600"},
    "tickets": {"id": "TK-1234", "created": "2026-06-15T09:00:00Z"},
    "legacy": {"order_date": "06/15/2026", "status": "shipped"},
}

# 正規化後: モデルに渡るデータ
for source, output in raw_outputs.items():
    normalized = post_tool_use_hook(source, output)
    print(f"{source}: {normalized}")

# crm:     {"customer": "田中太郎", "last_contact": "2024-06-15T00:00:00+00:00"}
# tickets: {"id": "TK-1234", "created": "2026-06-15T09:00:00Z"}
# legacy:  {"order_date": "2026-06-15T00:00:00+00:00", "status": "shipped"}

モデルが受け取るデータは常に統一された形式になります。

このパターンが有効な他のケース

PostToolUse フックによる正規化は、日付以外にも応用できます。

ケース 正規化内容
通貨の形式統一 "$1,234.56" / "1234.56 USD" / "¥1234" → 統一フォーマット
電話番号の正規化 "090-1234-5678" / "+819012345678" → E.164 形式
ステータス値のマッピング "active" / "ACTIVE" / "1" / true → 統一された列挙値
PII のマスキング メールアドレスや電話番号をマスク → モデルに個人情報を渡さない
レスポンスサイズの制限 巨大な配列を切り詰め → トークン消費を削減

原則: 決定論的に解ける問題を確率的推論に任せない

この記事の核心は、タイムスタンプの正規化そのものではなく、どの処理をモデルに任せ、どの処理をコードで解くべきかという判断基準にあります。

コードで解くべき処理:

  • フォーマット変換(日付、通貨、電話番号)
  • データのバリデーション
  • 型変換(文字列 → 数値)
  • 決まったルールに基づくマッピング

モデルに任せるべき処理:

  • 自然言語の理解と生成
  • 文脈に応じた判断
  • 曖昧な要件の解釈
  • 複数のツールを組み合わせた推論

この線引きを意識するだけで、エージェントの信頼性は大きく向上します。

まとめ

  • 複数の MCP サーバーからのデータ形式の不一致は、プロンプトではなくコードで解決する
  • PostToolUse フックを使えば、モデルに渡る前にツール出力を確実に正規化できる
  • 「決定論的に解ける問題を確率的推論に任せない」という原則は、MCP エージェント設計全般に適用できる

Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

関連記事