MCP serverを作ると、最初はtoolを呼べるだけで満足しがちです。けれど実運用では、toolの戻り値がすぐ問題になります。LLMには短い説明を読ませたい。UIには表やボタンを出したい。監査ログには外部IDと承認IDを残したい。再実行では同じ操作を二重実行したくない。この全部をcontentの長いtextへ詰めると、後段のagentも人間reviewerも読み間違えます。
この記事では、MCPのstructuredContent設計に絞ります。toolの名前、入力、出力schema全体の設計は AIエージェント tool schema 設計ガイド、成果物を人間レビューへ返す形式は AIエージェント出力契約設計ガイド、2026-07-28仕様への移行順は MCP 2026-07-28 移行ガイド、toolの危険度ヒントと承認境界は MCP tool annotations 設計ガイド に分けます。
今回の論点は、MCP tool resultを、LLM向けの説明、UI向けの構造化値、監査向けの参照情報へ分離することです。
先に結論: contentは説明、structuredContentは契約にする
MCPのtool resultには、少なくとも次の役割があります。
| 場所 | 置くもの | 読む相手 |
|---|---|---|
content | LLMや人間に読ませる短い説明 | LLM、人間reviewer |
structuredContent | 後段処理が使うJSON値 | UI、agent、eval、workflow |
outputSchema | structuredContentの期待形 | client、validator、reviewer |
resource_link | 大きい成果物や別途読む資料への参照 | UI、人間、必要時のLLM |
isError | tool実行エラーかどうか | LLM、retry controller |
_meta | routing、cache、traceなど補助情報 | client、gateway、監査基盤 |
私なら、contentには「次に何をすべきか分かる説明」だけを置きます。正規の戻り値はstructuredContentへ置き、schemaで検証します。長いログ、CSV、HTML、スクリーンショット、添付ファイルはresource_linkやembedded resourceへ逃がします。
{
"resultType": "complete",
"content": [
{
"type": "text",
"text": "3件の未対応セキュリティfindingを見つけました。P1が1件あります。"
}
],
"structuredContent": {
"summary": {
"total": 3,
"highestSeverity": "P1",
"requiresHumanReview": true
},
"findings": [
{
"id": "sec-20260816-001",
"severity": "P1",
"title": "Admin token is exposed in build log",
"resourceUri": "mcp://security/findings/sec-20260816-001"
}
]
},
"isError": false
}
ここで大事なのは、LLMに読ませる文章と、UIや後段agentが使う値を同じものとして扱わないことです。
なおMCP仕様では、後方互換のためにstructuredContentを返すtoolは、同じ値をserializeしたTextContentも返すことが推奨されています。実務では、legacy clientへ渡す互換用textと、LLMに読ませたい短い説明を混同しないようにします。source of truthはoutputSchemaに沿ったstructuredContentで、content側はclient互換と人間向け要約のどちらを優先するかをtoolごとに決めます。
なぜtextだけでは壊れるのか
textだけのtool resultは実装が楽です。
{
"content": [
{
"type": "text",
"text": "Created ticket TCK-1042 and assigned it to Platform. Retry is safe."
}
]
}
ただし、この形だと次の判断がすべて自然言語parseになります。
- ticket IDはどれか
- assignmentは成功したのか、提案しただけか
- retryしてよいのか
- user-visibleな変更が起きたのか
- 監査ログでどのeventと結ぶのか
- UIに表示してよい情報と隠すべき情報はどれか
個人の小さなMCP serverなら耐えられます。組織利用やagent workflowに入れるなら弱いです。textは要約にとどめ、状態やIDは構造化します。
{
"content": [{ "type": "text", "text": "Ticket TCK-1042を内部メモとして作成しました。" }],
"structuredContent": {
"ticketId": "TCK-1042",
"visibility": "internal",
"status": "created",
"retrySafe": false,
"auditEventId": "evt_01JZ5J4G4CY5"
}
}
retrySafeをtextに埋めると、retry controllerは読めません。visibilityをtextに埋めると、UIはcustomer-visibleかinternalかを判定できません。MCPの出力は、会話のためだけでなく、後段の制御のためにも設計します。
outputSchemaは「きれいな型」ではなく検証点にする
structuredContentを返すなら、tool定義側のoutputSchemaも合わせます。
{
"name": "ticket.create_internal_note",
"description": "Create an internal-only support note. Does not notify the customer.",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"ticketId": { "type": "string", "pattern": "^TCK-[0-9]+$" },
"note": { "type": "string", "minLength": 20, "maxLength": 2000 }
},
"required": ["ticketId", "note"]
},
"outputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"ticketId": { "type": "string" },
"visibility": { "type": "string", "enum": ["internal"] },
"status": { "type": "string", "enum": ["created", "blocked", "failed"] },
"retrySafe": { "type": "boolean" },
"auditEventId": { "type": "string" }
},
"required": ["ticketId", "visibility", "status", "retrySafe", "auditEventId"]
}
}
schemaを置く目的は、TypeScriptの型を合わせることだけではありません。clientが結果を検証し、UIが表示を分岐し、evalが期待値を比較し、reviewerが「このtoolは何を返す契約か」を読めるようにするためです。
特にwrite系toolでは、私は次のfieldを優先します。
| field | 目的 |
|---|---|
status | 成功、blocked、failed、partialを機械判定する |
externalId | 外部システムの成果物へ戻れるようにする |
retrySafe | 自動再実行してよいか判断する |
requiresHumanReview | 完了ではなくreview待ちへ送る |
auditEventId | 監査ログと成果物を結ぶ |
redactionApplied | LLMへ渡す前に秘匿処理したか示す |
逆に、raw API responseをそのままstructuredContentへ返すのは避けます。内部token、不要な個人情報、長いstack trace、権限のないfieldが混ざりやすいからです。
contentには判断に必要な最小文だけを書く
contentは空にしない方が扱いやすいです。LLMがtool resultを読む場合、構造化値だけだと次の自然文行動を組み立てにくいことがあります。
ただし、contentに全部を書く必要はありません。
{
"content": [
{
"type": "text",
"text": "請求CSVの検証が完了しました。12行中2行がblockedです。詳細はstructuredContentと検証レポートを参照してください。"
}
],
"structuredContent": {
"checkedRows": 12,
"blockedRows": 2,
"reportUri": "mcp://billing/imports/import_01JZ/report"
}
}
私が置く基準は次の通りです。
- LLMが次の一手を決めるのに必要な要約だけを書く
- ID、状態、件数、retry可否は
structuredContentにも必ず置く - 長い詳細はresourceへ逃がす
- user-visibleな文章と内部判断メモを混ぜない
- secret、raw log、個人情報を
contentへ流さない
contentは説明であって、source of truthではありません。
resource_linkは大きい結果の逃がし先にする
検索結果、監査レポート、CSV検証結果、スクリーンショット、差分一覧のような大きい成果物をstructuredContentへ丸ごと入れると、contextもUIも重くなります。
この場合は、要約と参照を分けます。
{
"content": [
{
"type": "text",
"text": "Import dry-runが完了しました。blocked 2件、warning 5件です。"
},
{
"type": "resource_link",
"uri": "mcp://billing/imports/import_01JZ/report",
"name": "import_01JZ_report",
"title": "Billing import dry-run report",
"mimeType": "application/json"
}
],
"structuredContent": {
"operationHandle": "import_01JZ",
"blocked": 2,
"warnings": 5,
"reportUri": "mcp://billing/imports/import_01JZ/report"
}
}
resource_linkは「LLMへ全部読ませる」ためではなく、必要になったときに読む対象を明示するために使います。UIはlinkとして出せます。agentは必要ならresources/readへ進めます。監査ログはreportUriとoperation handleを保存できます。
注意点は、link先の権限です。tool resultにURIを返したからといって、誰でも読めてよいわけではありません。resource readでもtenant、role、scopeを検証します。
isErrorはprotocol errorと分ける
MCP toolsには、JSON-RPCのprotocol errorと、tool execution errorの2系統があります。ここを混ぜると、agentの自己修正が壊れます。
私なら次のように分けます。
| 状況 | 返し方 | 理由 |
|---|---|---|
| tool名が存在しない | JSON-RPC error | client / routingの問題 |
| request schemaが壊れている | JSON-RPC error | protocolとして受け取れない |
| 入力値が業務ルールに合わない | isError: true | LLMが引数を直せる |
| 外部APIがtimeoutした | isError: true + retry情報 | retry controllerが判断する |
| 権限が不足している | isError: true + required scope | 承認やscope追加へ戻す |
| policyで禁止された | isError: true + blocked reason | 迂回せず停止する |
{
"resultType": "complete",
"content": [
{
"type": "text",
"text": "ticketIdの形式が不正です。TCK-12345の形式で指定してください。"
}
],
"structuredContent": {
"status": "failed",
"errorKind": "validation_error",
"retrySafe": true,
"fieldErrors": [
{ "field": "ticketId", "message": "expected pattern ^TCK-[0-9]+$" }
]
},
"isError": true
}
isError: trueでもstructuredContentを返す価値があります。失敗の種類、retry可否、必要な承認、field errorを機械的に扱えるからです。
_metaへ業務データを逃がさない
_metaは便利ですが、業務上の戻り値を隠す場所にしない方がよいです。
悪い例は、UIに必要なstatusや外部IDを_metaだけへ入れることです。clientによっては_metaを保存・転送する扱いが異なり、LLMや後段agentから見えないことがあります。
私なら次のように分けます。
| 情報 | 置き場所 |
|---|---|
| toolの業務結果 | structuredContent |
| LLM向け説明 | content |
| 大きい成果物の参照 | resource_link + structuredContent.reportUri |
| trace id / cache key / rendering hint | _meta |
| secret / raw credential | 返さない |
_metaは制御面、structuredContentは成果物面です。ここを混ぜると、監査とUIが後から苦しくなります。
実装時のチェックリスト
MCP serverのtool resultをreviewするとき、私はこの順で見ます。
contentだけで業務状態を表現していないかstructuredContentがoutputSchemaに沿っているか- 成功、partial、blocked、failedを
statusで判定できるか - write系toolに
retrySafeとauditEventIdがあるか - 長い結果を
resource_linkへ逃がしているか isError: trueのときもfield errorやretry可否を返しているか_metaに業務上のsource of truthを置いていないか- secret、PII、raw logをLLMへ渡す形で返していないか
この8項目が通れば、tool resultはLLMにもUIにも監査にも渡しやすくなります。
AGENTS.mdに置くならこの程度でよい
repo内のMCP serverルールとして固定するなら、巨大なschemaではなく原則を短く置きます。
## MCP tool result rules
- `content` is a short human/LLM summary, not the source of truth.
- Put machine-readable status, ids, retry safety, and audit ids in `structuredContent`.
- Define `outputSchema` for every write or workflow tool.
- Return long reports as `resource_link`; do not inline raw logs.
- Use `isError: true` for actionable tool execution errors.
- Never return secrets, raw credentials, or unnecessary PII in `content` or `structuredContent`.
実装側では、この原則をschema validation、snapshot test、evalへ落とします。文章だけで運用すると、busyな時期に必ず崩れます。
まとめ
MCPのstructuredContentは、きれいなJSONを返すための飾りではありません。tool resultをLLMの会話、UI表示、監査ログ、再実行制御へ分けるための契約です。
私なら、まずwrite系toolから始めます。contentは短い説明、structuredContentはstatus、externalId、retrySafe、requiresHumanReview、auditEventIdを持つJSON、長い詳細はresource_linkへ分離する。この形にすると、MCP serverを増やしても「何が起きたか」を後から追いやすくなります。