MCP サーバーを初めて自分の手で書き上げた日のことを、よく覚えています。Claude Desktop を再起動して、自作の集計ツールがツール一覧に現れた瞬間の、静かな高揚。
その高揚は、手元にある本物の CSV を渡した数分後には引いていました。ツールは呼ばれている。なのに、まともな数字が返ってこない。
Model Context Protocol そのものは、驚くほど素直な仕様です。つまずくのは、たいていプロトコルの外側にあります。スキーマの書き方、非同期の扱い方、モデルの出力を受け取る側の想定。
以下では MCP とエージェントワークフローの設計を一通り追いながら、個人開発の環境で私が実際に踏み抜いた4つの穴を、手元で測った数字とともに置いていきます。設計論だけで終わらせないために、壊れる条件のほうを先に共有させてください。
MCP は結局どこまでを引き受けてくれるのか
Model Context Protocol(MCP)は、Anthropicが2024年11月に発表したオープンな標準プロトコルです。AIモデルと外部ツール・データソースをつなぐための共通インターフェースを定義しており、「AIアプリケーションのUSB-C」とも呼ばれます。
MCPが解決する問題
従来のAIシステムでは、外部ツールとの連携のたびに専用のアダプターや統合コードを書く必要がありました。異なるAIモデルに同じツールを接続しようとすると、モデルごとに実装を書き直す必要があり、保守コストが増大していました。
MCPはこの問題を解決します。一度MCPサーバーとしてツールを実装すれば、Claude をはじめとする任意のMCP対応クライアントから利用できます。
MCPの構成要素
MCPは3つの主要コンポーネントで構成されています。
MCPホスト(Host) : Claude Desktopや Claude Codeのような、AIモデルを実行する環境。ユーザーとのインターフェースを提供し、MCPクライアントとしてサーバーと通信します。
MCPクライアント(Client) : ホスト内でMCPサーバーとの接続を管理するコンポーネント。各サーバーとの接続を確立し、リソース・ツール・プロンプトの一覧を取得します。
MCPサーバー(Server) : 実際の機能を提供するプログラム。ファイルシステム操作、データベースアクセス、Web検索など、あらゆる機能をMCPサーバーとして実装できます。
MCPが提供する3つのプリミティブ
MCPサーバーは以下の3種類のプリミティブを提供できます。
ツール(Tools) : Claudeが呼び出せる関数。ファイルの読み書き、API呼び出し、計算処理などの「アクション」を定義します。ツールはClaudeが判断して呼び出しを決定します。
リソース(Resources) : ファイル、データベースレコード、ドキュメントなど、静的・動的なデータへのアクセスを提供します。URIで識別され、コンテキストウィンドウに読み込まれます。
プロンプト(Prompts) : よく使うプロンプトテンプレートを再利用可能な形で定義します。ユーザーがスラッシュコマンドで呼び出せるような用途に最適です。
エージェントアーキテクチャの設計パターン
MCPを活用したシステムを設計する前に、エージェントアーキテクチャの主要なパターンを理解しましょう。
シングルエージェントパターン
最もシンプルな構成は、1つのClaudeインスタンスが複数のMCPツールを使いながらタスクを完遂するパターンです。
ユーザー
↓
Claude(オーケストレーター)
├── MCP: ファイルシステム
├── MCP: データベース
├── MCP: Web検索
└── MCP: メール送信
このパターンは、タスクが明確に定義されており、ツール間の調整が比較的単純な場合に適しています。Claude Desktopの通常の使い方が、このシングルエージェントパターンに相当します。
オーケストレーター + サブエージェントパターン
より複雑なタスクでは、親エージェント(オーケストレーター)がタスクを分割し、複数のサブエージェントに割り当てるパターンが効果的です。
ユーザー
↓
オーケストレーター(Claude)
├── サブエージェント1(調査担当)
│ └── MCP: Web検索、Wikipedia
├── サブエージェント2(分析担当)
│ └── MCP: データベース、計算ツール
└── サブエージェント3(出力担当)
└── MCP: ファイル生成、メール送信
Anthropicが2025年に公開した Claude Agent SDK では、このパターンがネイティブにサポートされています。Agent クラスを使って各役割のエージェントを定義し、オーケストレーターが orchestrate() メソッドで全体を制御します。
並列エージェントパターン
独立したタスクを複数のエージェントが同時に処理するパターンです。ここで私は、いちばん気づきにくい失敗をしました。
async def で包み、asyncio.gather に渡す。形は完全に並列です。ところが中で呼んでいるのが同期クライアントだと、待っている間イベントループが丸ごと止まります。並列に見えて、実際には順番待ちのままです。
手元で確かめました。0.5 秒かかる同期処理を4つ、asyncio.gather に渡すだけの検証です(Python 3.10.12)。
書き方 4タスク(各0.5秒)の実測
async def の中で同期呼び出し2.00 秒
asyncio.to_thread 経由0.50 秒
きれいに4倍。並列化したつもりのコードが、直列のまま動いていたわけです。しかも例外は一つも出ません。エージェントを8体に増やせば、素直に8倍待たされます。
直し方は二つあります。素直なのは非同期クライアントを使うこと。既存の同期処理を活かすなら asyncio.to_thread で別スレッドに逃がします。
import asyncio
from anthropic import AsyncAnthropic
client = AsyncAnthropic()
def first_text (response) -> str :
"""content[0] がツール使用ブロックのことがあるため、テキストを探して取り出す"""
for block in response.content:
if getattr (block, "type" , None ) == "text" :
return block.text
return ""
async def run_agent (task: str , tools: list ) -> str :
"""個別エージェントの実行(await できるクライアントを使う)"""
response = await client.messages.create(
model = "claude-opus-4-6" ,
max_tokens = 4096 ,
tools = tools,
messages = [{ "role" : "user" , "content" : task}],
)
return first_text(response)
async def parallel_workflow (tasks: list[ dict ]) -> list[ str | BaseException ]:
"""複数タスクを並列実行。1体の失敗で全体を落とさない"""
coroutines = [run_agent(t[ "task" ], t[ "tools" ]) for t in tasks]
return await asyncio.gather( * coroutines, return_exceptions = True )
first_text を挟んだのにも理由があります。tools を渡した応答では content[0] がツール使用ブロックになることがあり、.text を直に読むと AttributeError で落ちます。ツールを使わせたい呼び出しほど、この形で落ちます。
return_exceptions=True も実運用では効きます。既定のままだと、1体が例外を投げた時点で gather 全体が中断し、成功していた他のエージェントの結果まで捨てられます。
並列パターンを使う際は、各エージェントが互いに独立していることも確認してください。共有リソースへの競合書き込みは、データ整合性の問題を引き起こします。
チェックポイント付きシーケンシャルパターン
長時間かかるワークフローでは、各ステップの完了後に状態を保存するチェックポイント機構が重要です。途中で失敗しても、最初からやり直す必要がなくなります。
集計ツールを一つ作り、実データで壊す
ここからは TypeScript SDK でデータ集計ツールを実装します。ただし、私が最初に書いた版は本物の CSV を一行も処理できませんでした。先にその理由を潰した形で載せ、直後に「何がどう壊れていたか」を実測で並べます。
環境セットアップ
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node
基本的なMCPサーバーの実装
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" ;
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;
import { z } from "zod" ;
const server = new McpServer ({
name: "data-analyzer" ,
version: "1.0.0" ,
});
// データ集計ツールの定義
server. tool (
"aggregate_data" ,
"表形式データの指定カラムを集計して統計情報を返す" ,
{
// 実データには文字列カラムが必ず混ざる。値の型はここでは緩く受け、集計側で絞り込む
data: z. array (z. record (z. string (), z. unknown ())). describe ( "集計対象の行配列" ),
column: z. string (). describe ( "集計するカラム名" ),
operation: z. enum ([ "sum" , "average" , "max" , "min" ]). describe ( "集計操作" ),
},
async ({ data , column , operation }) => {
const raw = data. map ( row => row[column]);
// undefined だけでなく null・数値文字列・NaN・Infinity も落とす
const values = raw. filter (( v ) : v is number => typeof v === "number" && Number. isFinite (v));
const skipped = raw. length - values. length ;
// 空集合を数値で答えない。Claude に「無かった」と言葉で伝える
if (values. length === 0 ) {
return {
isError: true ,
content: [{
type: "text" ,
text: `カラム "${ column }" に数値が1件もありませんでした(${ raw . length } 行を検査)。` +
`カラム名の綴りか、値が文字列として格納されていないかをご確認ください。` ,
}],
};
}
let result : number ;
switch (operation) {
case "sum" : result = values. reduce (( a , b ) => a + b, 0 ); break ;
case "average" : result = values. reduce (( a , b ) => a + b, 0 ) / values. length ; break ;
// spread は要素数が増えるとコールスタックを溢れさせるため reduce で畳む
case "max" : result = values. reduce (( a , b ) => (b > a ? b : a)); break ;
case "min" : result = values. reduce (( a , b ) => (b < a ? b : a)); break ;
default : {
const exhaustive : never = operation;
throw new Error ( `未対応の operation です: ${ String ( exhaustive ) }` );
}
}
return {
content: [{
type: "text" ,
// skipped を返すと、Claude が「一部しか集計できていない」ことに気づける
text: JSON . stringify ({ column, operation, result, count: values. length , skipped }),
}],
};
}
);
// リソースの定義(テンプレートURI)
server. resource (
"report" ,
new ResourceTemplate ( "report://{date}" , { list: undefined }),
async ( uri , { date }) => ({
contents: [{
uri: uri.href,
text: `${ date }のレポートデータ(サンプル)` ,
}],
})
);
// STDIOトランスポートで起動
const transport = new StdioServerTransport ();
await server. connect (transport);
実データを入れた瞬間に起きたこと
上のコードで潰した4点は、いずれも私が実際に踏んだものです。Node.js v22.23.2 と zod 4.4.3 の手元環境で、修正前の実装に同じ入力を流して挙動を控えました。
渡した入力 修正前の実装の挙動 Claude 側に届くもの
文字列カラムを含む実際の CSV(例: city と price) zod が expected number, received string で配列全体を拒否 ツール呼び出し自体が失敗
該当カラムに数値が無い状態で max -Infinity を返す{"result": null}
該当カラムに数値が無い状態で average NaN を返す{"result": null}
20万行に max RangeError: Maximum call stack size exceeded応答なし
私自身がいちばん時間を溶かしたのは、最初の行でした。z.record(z.string(), z.number()) は「すべての値が数値である行」しか通しません。都市名や商品名の列が一つでも混ざれば、集計したいカラムが正しくても配列ごと弾かれます。私はこれを、スキーマが厳密であることの美徳だと思い込んでいました。実際には、ツールが呼ばれた形跡だけが残り、原因が分からないまま Claude が言い訳めいた文章を返す状態を作っていただけでした。
2行目と3行目も、種類の違う厄介さがあります。JSON.stringify は -Infinity も NaN も等しく null に変換します。つまり Claude の側からは、「該当データが1件も無かった」と「集計結果がたまたま空だった」の区別がつきません。数値で答えられない場面で数値の器を返すと、モデルは黙って穴を埋めにいきます。
境界値は、言葉で返す。これが今の私の基準です。isError: true を添えて何が起きたかを日本語で書いておくと、Claude は「カラム名を確認しましょうか」と自分から次の一手を出してきます。
4行目は単純な実装の癖です。Math.max(...values) の spread は、要素数がコールスタックの上限に触れると落ちます。10万行は通り、20万行で RangeError になりました。境界がデータ量に依存するため、開発中の小さなサンプルでは絶対に表面化しません。集計ツールを名乗る以上、reduce で畳んでおくのが無難です。
Claude DesktopへのMCPサーバー登録
実装したサーバーをClaude Desktopで使うには、設定ファイルを編集します。
{
"mcpServers" : {
"data-analyzer" : {
"command" : "node" ,
"args" : [ "/path/to/my-mcp-server/dist/index.js" ],
"env" : {
"NODE_ENV" : "production"
}
}
}
}
設定後、Claude Desktopを再起動するとMCPサーバーが認識され、aggregate_data ツールが使えるようになります。
実務ワークフローの設計:3つのユースケース
理論を学んだところで、実際のビジネスシナリオにどう適用するかを見ていきます。
ユースケース1:毎日の情報収集・要約レポート作成
朝、特定のニュースソースや RSS フィードから情報を収集し、要約レポートを作成して Slack に送信する自動化システムです。
必要なMCPツール :
Web スクレイピングツール(情報収集)
テキスト要約ツール(Claude APIを内部で使用)
Slack送信ツール
エージェントワークフロー :
from anthropic import Anthropic
client = Anthropic()
def daily_report_workflow (sources: list[ str ]) -> str :
"""毎日のレポート生成ワークフロー"""
# Step 1: 各ソースから情報収集(並列)
collection_prompt = f """
以下のニュースソースから今日の重要な情報を収集してください:
{ ', ' .join(sources) }
各ソースについて:
1. fetch_webpageツールでページを取得
2. 最新5件の記事タイトルと概要を抽出
3. 重要度を1-5で評価
"""
collection_result = client.messages.create(
model = "claude-opus-4-6" ,
max_tokens = 8192 ,
messages = [{ "role" : "user" , "content" : collection_prompt}]
)
# Step 2: 収集した情報を分析・要約
summary_prompt = f """
収集した情報を分析し、今日のエグゼクティブサマリーを作成してください。
収集情報:
{ collection_result.content[ 0 ].text }
サマリーの要件:
- 最重要トピック3件をトップに
- 各トピックは3行以内で簡潔に
- 業界への影響と推奨アクションを含める
- Slack での読みやすさを考慮したMarkdown形式
"""
summary = client.messages.create(
model = "claude-opus-4-6" ,
max_tokens = 4096 ,
messages = [{ "role" : "user" , "content" : summary_prompt}]
)
return summary.content[ 0 ].text
ユースケース2:コードレビュー自動化パイプライン
GitHub のプルリクエストを自動的に分析し、コードの品質、セキュリティ上の問題、パフォーマンスへの影響をレポートするシステムです。
エージェントの役割分担 :
コード取得エージェント : GitHub APIでPRの差分を取得
品質分析エージェント : コーディング規約の遵守、可読性を評価
セキュリティ分析エージェント : 潜在的な脆弱性を検出
パフォーマンス分析エージェント : 計算量、メモリ使用量を評価
レポート集約エージェント : 各分析結果を統合してコメントを作成
このパターンでは、分析1〜4を並列実行することで、シングルエージェントに比べて処理時間を大幅に短縮できます。
ユースケース3:カスタマーサポートの一次対応自動化
問い合わせメールを受信し、内容を分類、FAQ から回答を生成、必要に応じてエスカレーションを判定するシステムです。
判断ロジックの実装ポイント :
ここで私が最初にやったのは、「JSON形式で回答してください」と頼んで json.loads に流し込むことでした。動きます。8割方は。
残りの2割で落ちます。モデルが親切心から「承知しました。以下が分類結果です」と前置きを付けたり、コードフェンスで囲んだりするたび、json.loads は JSONDecodeError を投げます。プロンプトを強く書き直しても、確率が下がるだけで消えません。
文章としての JSON を期待するのをやめ、ツール定義として構造を渡すほうが確実でした。
import json
TRIAGE_TOOL = {
"name" : "record_triage" ,
"description" : "問い合わせの分類結果を記録する" ,
"input_schema" : {
"type" : "object" ,
"properties" : {
"category" : { "type" : "string" , "enum" : [ "billing" , "technical" , "general" , "complaint" ]},
"urgency" : { "type" : "string" , "enum" : [ "high" , "medium" , "low" ]},
"reason" : { "type" : "string" , "description" : "その判断に至った根拠" },
},
"required" : [ "category" , "urgency" , "reason" ],
},
}
def triage_inquiry (email_content: str ) -> dict :
"""問い合わせのトリアージ。構造化された結果だけを受け取る"""
response = client.messages.create(
model = "claude-opus-4-6" ,
max_tokens = 1024 ,
system = (
"あなたはカスタマーサポートのトリアージ担当です。"
"問い合わせを分類し、record_triage ツールで結果を記録してください。 \n "
"urgency の目安: high は即座に人間が対応、medium は4時間以内、low は24時間以内。"
),
tools = [ TRIAGE_TOOL ],
tool_choice = { "type" : "tool" , "name" : "record_triage" }, # 必ずこのツールを使わせる
messages = [{ "role" : "user" , "content" : email_content}],
)
for block in response.content:
if getattr (block, "type" , None ) == "tool_use" and block.name == "record_triage" :
return block.input # 既にスキーマ検証済みの dict
# ここに来るのは想定外。人間のキューへ回す
raise RuntimeError ( "トリアージ結果を取得できませんでした" )
tool_choice でツールの使用を強制すると、返ってくるのは文字列ではなくスキーマに沿った辞書です。パースの心配が消えるうえ、enum から外れた値も来なくなります。例外時に握りつぶさず人間のキューへ回しているのも意図的です。分類できない問い合わせを general に丸めると、いちばん困っている人が最後尾に並びます。
エラー処理と信頼性の向上
本番環境でエージェントワークフローを運用するには、堅牢なエラー処理が不可欠です。
リトライメカニズムの実装
一時的なエラー(ネットワーク障害、レート制限など)に対しては、指数バックオフを使ったリトライが効果的です。
import time
import random
from typing import Callable, TypeVar
T = TypeVar( 'T' )
def with_retry (
func: Callable[[], T],
max_attempts: int = 3 ,
base_delay: float = 1.0 ,
max_delay: float = 60.0
) -> T:
"""指数バックオフ + ジッターによるリトライ"""
for attempt in range (max_attempts):
try :
return func()
except Exception as e:
if attempt == max_attempts - 1 :
raise # 最後の試行は例外を再スロー
# 指数バックオフ + ランダムジッター
delay = min (base_delay * ( 2 ** attempt) + random.uniform( 0 , 1 ), max_delay)
print ( f "Attempt { attempt + 1 } failed: { e } . Retrying in { delay :.1f } s..." )
time.sleep(delay)
エラーの分類と対処
エラーには、リトライで回復できるものとできないものがあります。
リトライ可能なエラー :
HTTP 429(レート制限): retry-after ヘッダーに従って待機
HTTP 502/503(一時的なサーバーエラー): 指数バックオフで再試行
ネットワークタイムアウト: 設定されたリトライ回数まで再試行
リトライ不可なエラー :
HTTP 401(認証エラー): APIキーを確認
HTTP 400(不正なリクエスト): リクエストの形式を修正
コンテキストウィンドウ超過: 入力を分割して再設計
サーキットブレーカーパターン
特定のツールやサービスが連続して失敗する場合、一時的にそのサービスへのアクセスを停止し、システム全体への影響を防ぐサーキットブレーカーパターンが有効です。
ログ管理とオブザーバビリティ
エージェントワークフローの動作を理解し、問題を迅速に発見するには、適切なログ管理が必要です。
構造化ログの実装
import logging
import json
from datetime import datetime, timezone
class AgentLogger :
def __init__ (self, workflow_id: str ):
self .workflow_id = workflow_id
self .logger = logging.getLogger( f "agent. { workflow_id } " )
def log_tool_call (self, tool_name: str , input_data: dict , output_data: dict , duration_ms: float ):
"""ツール呼び出しのログ"""
self .logger.info(json.dumps({
"timestamp" : datetime.now(timezone.utc).isoformat(),
"workflow_id" : self .workflow_id,
"event" : "tool_call" ,
"tool" : tool_name,
"input_tokens" : len ( str (input_data)),
"output_tokens" : len ( str (output_data)),
"duration_ms" : duration_ms,
}))
def log_agent_decision (self, agent_id: str , decision: str , reasoning: str ):
"""エージェントの判断ログ"""
self .logger.info(json.dumps({
"timestamp" : datetime.now(timezone.utc).isoformat(),
"workflow_id" : self .workflow_id,
"event" : "agent_decision" ,
"agent_id" : agent_id,
"decision" : decision,
"reasoning" : reasoning[: 500 ], # 長すぎる場合は切り詰め
}))
追跡すべき主要メトリクス
実運用では以下のメトリクスを継続的に監視することを推奨します。
コスト関連 : 1ワークフローあたりのトークン使用量、モデル別のコスト内訳、月次コスト推移。
パフォーマンス関連 : エンドツーエンドのレイテンシ、各エージェントの処理時間、ツール呼び出しの平均時間。
信頼性関連 : ワークフロー成功率、エラー種別ごとの発生頻度、リトライ率。
コスト最適化の戦略
Claude APIのコストは、主にトークン使用量によって決まります。エージェントワークフローではAPI呼び出しが多くなりがちなので、コスト管理は特に重要です。
モデルの使い分け
すべてのタスクに最高性能のモデルを使う必要はありません。タスクの複雑さに応じてモデルを選択することで、コストを大幅に削減できます。
タスク種別 推奨モデル 理由
単純な分類・抽出 claude-haiku-4-5 高速・低コスト
中程度の推論 claude-sonnet-4-6 バランス
高度な推論・長文生成 claude-opus-4-6 最高品質
プロンプトキャッシングの活用
システムプロンプトや共通のコンテキストが繰り返し使われる場合、Anthropicのプロンプトキャッシング機能を使えば、コストを最大90%削減できます。
response = client.messages.create(
model = "claude-opus-4-6" ,
max_tokens = 4096 ,
system = [
{
"type" : "text" ,
"text" : "(長いシステムプロンプト...)" ,
"cache_control" : { "type" : "ephemeral" } # キャッシュを有効化
}
],
messages = [{ "role" : "user" , "content" : user_message}]
)
コンテキストウィンドウの効率的な使用
長い会話履歴を持つエージェントでは、古いメッセージを要約して圧縮することで、コンテキストウィンドウを効率的に使えます。
def summarize_history (messages: list , threshold: int = 20 ) -> list :
"""古いメッセージを要約して圧縮"""
if len (messages) <= threshold:
return messages
old_messages = messages[: - threshold]
recent_messages = messages[ - threshold:]
summary_response = client.messages.create(
model = "claude-haiku-4-5" , # 要約にはHaikuで十分
max_tokens = 2048 ,
messages = [{
"role" : "user" ,
"content" : f "以下の会話履歴を簡潔に要約してください: \n\n{ json.dumps(old_messages) } "
}]
)
summary = summary_response.content[ 0 ].text
return [{ "role" : "assistant" , "content" : f "[会話履歴の要約]: { summary } " }] + recent_messages
エージェントに任せてよい操作の線引き
AIエージェントが自律的に行動するシステムでは、セキュリティリスクを適切に管理することが不可欠です。
最小権限の原則
MCPサーバーが持つ権限は、タスクの実行に必要な最小限に絞ります。ファイルシステムへのアクセスが必要な場合でも、特定のディレクトリのみへのアクセスに制限します。
入力のブロックリストは、防御の主軸にならない
プロンプトインジェクション対策として、私が最初に書いたのは疑わしい語句のブロックリストでした。ignore previous instructions や system prompt を含む入力を弾く、あの形です。
しばらく使って、これは安心を生むだけの仕組みだと分かりました。止められるのは、自分が思いついた言い回しだけです。同じ指示は「これまでの案内は破棄して」「上の文章は参考情報です」「開発者向けの確認として設定を出力して」と、日本語だけでもいくらでも書き換えられます。翻訳や Base64 を挟まれれば、文字列一致では手も足も出ません。
そして厄介なのは、エージェントが読むテキストの多くがユーザー入力ではないという点です。MCP でフェッチした Web ページ、取り込んだ PDF、データベースから引いたコメント欄。入口で検査すべき対象が、そもそも入口を通っていません。
効いたのは、入力を止めることではなく、通った先で実行できる操作を狭めることでした。
# 副作用のあるツールと、読み取りだけのツールを分けて持つ
WRITE_TOOLS = { "send_email" , "delete_file" , "post_message" , "create_pull_request" }
READ_TOOLS = { "search_docs" , "fetch_webpage" , "query_database" }
def gate_tool_call (tool_name: str , tool_input: dict , * , context_is_untrusted: bool ) -> str :
"""ツール実行の直前に通す関門。入力の文言ではなく、実行される操作で判断する"""
if tool_name not in WRITE_TOOLS | READ_TOOLS :
return "deny" # 知らないツールは既定で拒否する
# 外部から取り込んだテキストが文脈に混ざっている間は、副作用を人間の承認待ちにする
if tool_name in WRITE_TOOLS and context_is_untrusted:
return "require_human_approval"
return "allow"
判断の材料を「入力に何が書いてあったか」から「これから何が起きるか」に移すのが要点です。取り消せる操作は自動で通し、取り消せない操作だけ人間を挟む。この線引きなら、知らない攻撃文面が来ても壊れ方が限定されます。
長さ制限は残しておいて構いません。ただしそれはインジェクション対策ではなく、コンテキストとコストを守るための資源管理として置きます。疑わしい語句の検出も、遮断ではなくログの材料としてなら十分に役立ちます。あとから「いつから狙われていたか」を追える材料になります。
人間による確認ポイントの設計
すべてのアクションをエージェントに自動実行させるのではなく、重要な判断ポイントで人間の確認を要求する設計も重要です。「承認が必要な場合はフラグを立て、それ以外は自動実行」というハイブリッドアプローチが実用的です。
判定の軸は、危険度よりも可逆性に置くと迷いません。下書きの作成・ラベル付け・検索は自動で回し、送信・削除・課金・外部への公開は承認待ちにする。私自身、最初は「重要そうな操作」で線を引こうとして、毎回その場の気分で判断がぶれました。
次に手を動かすなら
4つの穴を並べてきましたが、共通していたのは「例外が出ない失敗」だったと思っています。zod が配列を弾いても、gather が直列に戻っても、-Infinity が null になっても、ログには何も残りませんでした。動いているように見えるものほど、確かめる機会を失います。
今すでに動いている MCP ツールをお持ちでしたら、一つだけ試していただきたいことがあります。空の配列と、想定の10倍の行数を、そのツールに直接投げてみてください。Claude を経由せず、コマンドラインからで構いません。私の場合、それだけで返してはいけない値を返している箇所が二つ見つかりました。
並列化しているコードがあれば、time.perf_counter() で前後を挟むだけでも十分です。想定した時間で終わっていなければ、どこかで待ち合わせが起きています。
MCP は、AI に外の世界を触らせる仕組みです。触れる範囲が広がるほど、壊れ方は静かになっていきます。設計の正しさと同じ熱量で、壊れ方を確かめる時間を取れたらと考えています。
長い記事にお付き合いいただき、ありがとうございました。ここに書いたものはどれも私が転んだ跡で、まだ見つけていない穴のほうが多いはずです。同じ場所でつまずいた方がいらしたら、その知見をぜひ教えてください。
日々の業務の中で「もし自動化できたら」と感じる場面を見つけたとき、そこにエージェントワークフローの可能性があります。ぜひ、この記事を参考に最初の一歩を踏み出してみてください。