「最近のノートを一覧して」と頼んだのに、エージェントが呼んだのは fetch_all_raw ―― 1万2千行の全文をコンテキストに積み込む道具でした。自作MCPサーバーにツールを11個登録して、AIに間違った道具を選ばせ続けた失敗の記録です。この事故、エラーは1つも出ません。コードのバグでもありません。壊れていたのはdescriptionの設計でした。この記事では20問の実測で選択精度を60%から95%まで直した、道具が選ばれるdescription設計の5つの型と、コピペで動く完全なコードを載せます。
何が起きたか: 「一覧」の依頼が全文ダンプを呼ぶ
実測のタイムラインです。
| とき | 起きたこと |
|---|---|
| 初日 21:10 | ツール11個を登録。一覧表示・単発呼び出しの確認は全部成功 |
| 初日 21:15 | 「最近のノートを一覧して」→ 呼ばれたのは fetch_all_raw。1万2千行が積まれ、応答まで40秒 |
| 初日 21:40 | 「このノートを整理して」→ 呼ばれたのは update_note。既存本文がAIの要約で上書きされかけた |
| 翌朝 | 20問テストを実施。正しく選べたのは12問(60%)。誤選択8問のうち5問が fetch_all_raw |
症状を整理すると、1つの「選び間違い」に対して3つの原因が積み重なっていました。
| 原因 | 症状 | 正しい対策 |
|---|---|---|
| descriptionが2〜3文字(「検索」「ノート」) | ツール間の違いをAIが読み取れず、名前が近いものに流れる | 使う場面・使わない場面・引数の例を書く |
| 役割が重複するツールの並存(fetch_all_raw と export_notes) | 「一覧」の依頼がどちらにもマッチし、全文ダンプ側に流れる | 1ツール1責務で重複を潰す |
| 命名規約の未統一(fetch/get/list の使い分けが説明に無い) | get_note と fetch_all_raw の「取得」の違いが伝わらない | 動詞_目的語の命名規約を統一する |
エラーが出ないのが質が悪いところです。fetch_all_raw は依頼通り「一覧」を返します。巨大なだけで。だから単体テストは全部通り、事故は実運用の会話の中でだけ起きました。
失敗した設計: 11個のツールと3文字のdescription
リリース時点で私が書いていたサーバーがこちらです。動作自体は正しく、11個のツールが全部呼べます。
# 失敗した設計: ツール11個・description雑・命名バラバラ
import sqlite3
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("knowledge-base")
db = sqlite3.connect("kb.db")
db.execute("""CREATE TABLE IF NOT EXISTS notes(
id INTEGER PRIMARY KEY, title TEXT, body TEXT, tags TEXT, updated TEXT)""")
@mcp.tool()
def fetch_all_raw() -> str:
"""データを取得"""
rows = db.execute("SELECT * FROM notes").fetchall()
return str(rows)
@mcp.tool()
def get_note(note_id: int) -> str:
"""ノート"""
row = db.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
return str(row)
@mcp.tool()
def search_notes(q: str) -> str:
"""検索"""
rows = db.execute(
"SELECT id, title FROM notes WHERE title LIKE ? OR body LIKE ?",
(f"%{q}%", f"%{q}%")).fetchall()
return str(rows)
@mcp.tool()
def list_tags() -> str:
"""タグ"""
rows = db.execute("SELECT DISTINCT tags FROM notes").fetchall()
return str(rows)
@mcp.tool()
def tag_notes(tag: str) -> str:
"""タグ付け"""
rows = db.execute(
"SELECT id, title FROM notes WHERE tags LIKE ?", (f"%{tag}%",)).fetchall()
return str(rows)
@mcp.tool()
def create_note(title: str, body: str, tags: str = "") -> str:
"""作成"""
db.execute(
"INSERT INTO notes(title, body, tags, updated) VALUES(?,?,?,datetime('now'))",
(title, body, tags))
db.commit()
return "ok"
@mcp.tool()
def update_note(note_id: int, title: str = None, body: str = None) -> str:
"""更新"""
if title:
db.execute("UPDATE notes SET title=? WHERE id=?", (title, note_id))
if body:
db.execute("UPDATE notes SET body=? WHERE id=?", (body, note_id))
db.commit()
return "ok"
@mcp.tool()
def delete_note(note_id: int) -> str:
"""削除"""
db.execute("DELETE FROM notes WHERE id=?", (note_id,))
db.commit()
return "ok"
@mcp.tool()
def purge_old(days: int) -> str:
"""古いのを消す"""
db.execute(
"DELETE FROM notes WHERE updated < datetime('now', ?)", (f"-{days} days",))
db.commit()
return "ok"
@mcp.tool()
def export_notes() -> str:
"""エクスポート"""
rows = db.execute("SELECT * FROM notes").fetchall()
return "\n".join(",".join(str(c) for c in r) for r in rows)
@mcp.tool()
def sync_status() -> str:
"""状態"""
n = db.execute("SELECT COUNT(*) FROM notes").fetchone()[0]
return f"notes={n}"
if __name__ == "__main__":
mcp.run(transport="stdio")
見ての通り、関数名とdocstringだけ書いて「AIは名前から意味を推渮してくれる」と信じていました。実測の誤選択はこうでした。
| 依頼 | エージェントが呼んだツール | 起きたこと |
|---|---|---|
| 「最近のノートを一覧して」 | fetch_all_raw | 1万2千行の全文が積まれ、応答40秒・以後の会話が鈍化 |
| 「ノートを出力して」 | export_notes ではなく fetch_all_raw | 整形されないtuple文字列がそのまま返る |
| 「このノートを整理して」 | update_note | 本文がAIの要約で上書きされる寸前(確認して停止) |
| 「タグの使い方を確認して」 | tag_notes ではなく list_tags | 「タグ」という語だけで近い名前に流れる |
実測: 20問テストで選択精度を比較する
Claude Desktop越しに日常で使いそうな20の依頼文(一覧・検索・統計・閲覧系)を投げ、正しいツールを選べたかを手で採点しました。
| 設計 | 正しく選べた | 誤選択の内訳 | 平均応答時間 |
|---|---|---|---|
| 失敗版(ツール11個) | 12/20(60%) | fetch_all_raw 5回・export_notes 2回・update_note 1回 | 28秒 |
| 修正版(ツール5個) | 19/20(95%) | get_stats を search_notes と取り違え1回 | 4秒 |
応答時間が7分の1になったのは、全文ダンプが消えたからです。精度向上は「AIが賢くなった」のではなく「選択肢が設計された」効果です。なお書き込み系(create/update/delete/purge)は別サーバーに分離して普段は接続しない構成にしています。読み取り専用モードの詳細は過去の記事に譲り、この記事では「選ばれる」設計に絞ります。
正しい設計: 読み取り5個・description設計適用のサーバー
修正版の全文です。descriptionがコードの主役になっています。
# 正しい設計: ツール5個・型1〜5を適用・書き込みは別サーバーに分離済み
import os
import sqlite3
from mcp.server.fastmcp import FastMCP
# 初回のみテーブルを作る(ro接続では作れないため、起動前に1度だけ書き込む)
if not os.path.exists("kb.db"):
init = sqlite3.connect("kb.db")
init.execute("""CREATE TABLE IF NOT EXISTS notes(
id INTEGER PRIMARY KEY, title TEXT, body TEXT, tags TEXT, updated TEXT)""")
init.commit()
init.close()
mcp = FastMCP("knowledge-base")
db = sqlite3.connect("file:kb.db?mode=ro", uri=True)
@mcp.tool()
def search_notes(query: str, limit: int = 5) -> str:
"""キーワードでノートを検索し、id・title・更新日を1行1件で返す。
使う場面: 探したい内容を表すキーワードが決まっているとき。
使わない場面: キーワードが無く「最近のノート」が欲しいだけなら list_recent_notes を使う。
例: query="RAG 検索精度", limit=5
"""
rows = db.execute(
"SELECT id, title, updated FROM notes "
"WHERE title LIKE ? OR body LIKE ? ORDER BY updated DESC LIMIT ?",
(f"%{query}%", f"%{query}%", limit)).fetchall()
if not rows:
return "該当なし。キーワードを短くするか、list_recent_notes で一覧から探してください。"
return "\n".join(f"{r[0]}: {r[1]}({r[2]})" for r in rows)
@mcp.tool()
def get_note(note_id: int) -> str:
"""ノート1件の本文全文を返す。
使う場面: search_notes か list_recent_notes で得た id を指定できるときだけ。
使わない場面: id が分からないなら先に search_notes を呼ぶこと。全文を一括で取る手段は本サーバーに存在しない。
例: note_id=42
"""
row = db.execute(
"SELECT id, title, body, tags, updated FROM notes WHERE id=?",
(note_id,)).fetchone()
if not row:
return f"id={note_id} は存在しません。search_notes で id を確認してください。"
return f"## {row[1]}\n{row[2]}\n\ntags: {row[3]} / updated: {row[4]}"
@mcp.tool()
def list_recent_notes(limit: int = 10) -> str:
"""更新が新しい順に、ノート一覧(id・title・更新日のみ)を返す。
使う場面: キーワードが無く、最近書いたノートを俯瞰したいとき。
使わない場面: 探す対象が決まっているなら search_notes。本文が要るなら get_note。
例: limit=10
"""
rows = db.execute(
"SELECT id, title, updated FROM notes ORDER BY updated DESC LIMIT ?",
(limit,)).fetchall()
return "\n".join(f"{r[0]}: {r[1]}({r[2]})" for r in rows) or "ノートが0件です。"
@mcp.tool()
def list_tags() -> str:
"""ノートに付与済みのタグを、件数つきで一覧する。
使う場面: タグの種類や使われ具合を把握したいとき。
使わない場面: 特定のタグが付いたノート本文が欲しいなら、search_notes にそのタグ名を渡す。
例: なし
"""
counts = {}
for (tags,) in db.execute("SELECT tags FROM notes WHERE tags != ''"):
for t in tags.split(","):
t = t.strip()
if t:
counts[t] = counts.get(t, 0) + 1
if not counts:
return "タグは1件もありません。"
return "\n".join(f"{k}: {v}件" for k, v in sorted(counts.items(), key=lambda x: -x[1]))
@mcp.tool()
def get_stats() -> str:
"""ノート総数と90日以上未更新の古ノート数など、ナレッジベースの統計値を返す。
使う場面: 「どれくらい溜まっているか」のような全体量の質問のとき。
使わない場面: 具体的なノート内容に関する質問では使わない。search_notes を使う。
例: なし
"""
total = db.execute("SELECT COUNT(*) FROM notes").fetchone()[0]
old = db.execute(
"SELECT COUNT(*) FROM notes WHERE updated < datetime('now', '-90 days')"
).fetchone()[0]
return f"総数={total}件 / 90日以上未更新={old}件"
if __name__ == "__main__":
mcp.run(transport="stdio")
動作確認は pip install "mcp[cli]" した環境で python3 kb_server.py、クライアント側の設定ファイルにコマンドとして登録するだけで済みます。
description設計の5つの型
修正で効いた変更をルール化すると、次の5つに集約されます。
| 型 | ルール | 失敗版での症状 |
|---|---|---|
| 1: 動詞_目的語で命名する | search_notes のように「操作_対象」を統一 | fetch_all_raw は「取得」なのか「全取得」なのか名前から読めない |
| 2: 使う/使わないを両方書く | 使わない場合の代替先(別ツール名)まで明示 | ツール間の違いがdescriptionから読み取れない |
| 3: 引数の例を1行書く | query="RAG 検索精度" の形で具体例を提示 | AIが空文字や "all" のような意味不明の引数を投げる |
| 4: 1ツール1責務にする | 「まとめて取る」系の重複ツールを作らない | fetch_all_raw と export_notes の役割が重複し依頼が流れた |
| 5: 読み取りは5個までに削る | 書き込み系は別サーバーに分離して普段繋がない | 11個から1個を選ぶ精度が60%まで落ちた |
型2が一番効きました。「使わない場面」に代替ツール名を書くことで、description自身がルーティング表として機能します。AIは間違えそうになった瞬間に、descriptionの中で正しい道具の名前を見つけられます。
機械的に守る: descriptionのリンタースクリプト
5つの型は人間の記憶には頼れません。そこで、サーバーのソースをASTで読んで型1〜4を機械検証するスクリプトを書きました。APIキー不要でそのまま動きます。
# check_tool_descriptions.py: description設計の型を機械的に検証する
# 使い方: python3 check_tool_descriptions.py kb_server.py
import ast
import re
import sys
VAGUE = re.compile(r"^(fetch|process|handle|run|do|get)_?(all|raw)?$")
def check(path: str) -> int:
tree = ast.parse(open(path, encoding="utf-8").read())
bad = 0
for node in ast.walk(tree):
if not isinstance(node, ast.FunctionDef):
continue
has_tool = any("tool" in ast.unparse(d) for d in node.decorator_list)
if not has_tool:
continue
name = node.name
doc = ast.get_docstring(node) or ""
errs = []
if VAGUE.match(name):
errs.append("命名が曖昧(型1)")
if "使わない場面" not in doc:
errs.append("使わない場面の記述なし(型2)")
if "例:" not in doc:
errs.append("引数の例なし(型3)")
if len(doc) < 60:
errs.append("descriptionが短すぎる(型4・実質の説明不足)")
bad += bool(errs)
mark = "OK" if not errs else "NG " + " / ".join(errs)
print(f"{'✓' if not errs else '×'} {name}: {mark}")
print(f"\nNGツール数: {bad}")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(check(sys.argv[1]))
失敗版にかけると11ツール全部がNG、修正版にかけると5ツール全部がOKになります。「レビューで頑張る」より「CIで落ちる」方が確実に守られるので、私はコミット時にこのスクリプトを回しています。残った1問の取り違え(get_stats と search_notes)は、両者のdescriptionに相互の代替先を書き足して再テストで潰しました。
まとめ
- ツールの選択精度は道具の数とdescriptionで決まる。11個・3文字で60%、5個・設計ありで95%(20問実測)
- 最大の効果は型2「使わない場面+代替ツール名」。descriptionがルーティング表として働く
- 全文ダンプ系ツール(fetch_all_raw)は応答時間28秒→4秒の主犯。作らないことが最速の解
- 書き込み系は別サーバーへ分離。読み取り専用で動かすほうが事故が根本から起きない
- 次のアクション: 自分のMCPサーバーのツール数とdescription文字数を数えてみる。3文字以下が1つでもいたら、この記事の5つの型で書き直すのが最短です。descriptionの書式は MCP公式仕様 にも定義があります
関連記事(同テーマ)
参考書籍: MCP実践入門 — Model Context Protocolで作るAIエージェント連携(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)