本文へ移動
🧾

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には、少なくとも次の役割があります。

場所置くもの読む相手
contentLLMや人間に読ませる短い説明LLM、人間reviewer
structuredContent後段処理が使うJSON値UI、agent、eval、workflow
outputSchemastructuredContentの期待形client、validator、reviewer
resource_link大きい成果物や別途読む資料への参照UI、人間、必要時のLLM
isErrortool実行エラーかどうかLLM、retry controller
_metarouting、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監査ログと成果物を結ぶ
redactionAppliedLLMへ渡す前に秘匿処理したか示す

逆に、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 errorclient / routingの問題
request schemaが壊れているJSON-RPC errorprotocolとして受け取れない
入力値が業務ルールに合わないisError: trueLLMが引数を直せる
外部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するとき、私はこの順で見ます。

  1. contentだけで業務状態を表現していないか
  2. structuredContentがoutputSchemaに沿っているか
  3. 成功、partial、blocked、failedをstatusで判定できるか
  4. write系toolにretrySafeとauditEventIdがあるか
  5. 長い結果をresource_linkへ逃がしているか
  6. isError: trueのときもfield errorやretry可否を返しているか
  7. _metaに業務上のsource of truthを置いていないか
  8. 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を増やしても「何が起きたか」を後から追いやすくなります。

参考

WRITTEN BY nidoneko

Full-stack engineer with 8+ years of experience in TypeScript, React, Node.js, and cloud-native development across healthcare, finance, HR, and IoT domains.

View Profile →