0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP ツールの outputSchema / structuredContent で戻り値を構造化する実装手順 — 宣言した瞬間に全呼び出しが落ちる等3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

自作 MCP サーバーのツールが「テキストを返すだけ」になっていないだろうか。JSON を渡したいのに、結局 JSON.stringify() した文字列を text で返してモデルにパースし直させている、という状態だ。

MCP の 2025-06-18 仕様で入った outputSchema / structuredContent を使えば、戻り値をスキーマ付きの構造化データとして返せる。TypeScript SDK での実装手順と、実際に踏んだ 3 つのハマりどころをまとめる。

想定読者

  • 自作 MCP サーバーを既に 1 本以上動かしている人
  • ツールの戻り値がテキストベタ返しになっていて気持ち悪い人

前提環境

  • Node.js 22.x
  • @modelcontextprotocol/sdk 1.12 以降(outputSchema 対応版)
  • プロトコルバージョン 2025-06-18
  • クライアント: Claude Code v2 系

TL;DR

  • ツール定義に outputSchema を足すと、戻り値が structuredContent として型付きで返せる
  • ただし outputSchema を宣言したら structuredContent は必須。忘れると全呼び出しが SDK 側の検証で落ちる
  • トップレベルが配列のスキーマは通らない。必ずオブジェクトで包む
  • 非対応クライアント向けに content(テキスト)も併記するのが安全

手順 / 動かし方

registerTool の第 2 引数に outputSchema を渡す。inputSchema と同じく Zod スキーマの「生の shape」を渡す形だ。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "metrics", version: "1.0.0" });

server.registerTool(
  "get_build_stats",
  {
    title: "ビルド統計の取得",
    description: "直近のビルド結果を集計して返す",
    inputSchema: { branch: z.string() },
    outputSchema: {
      total: z.number().describe("対象ビルド数"),
      failed: z.number().describe("失敗数"),
      builds: z.array(
        z.object({
          id: z.string(),
          status: z.enum(["success", "failure"]),
          durationSec: z.number(),
        })
      ),
    },
  },
  async ({ branch }) => {
    const result = await collectStats(branch);
    return {
      // 構造化データ本体
      structuredContent: result,
      // 非対応クライアント向けのフォールバック
      content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
    };
  }
);

動作確認は MCP Inspector が早い。

npx @modelcontextprotocol/inspector node dist/server.js

Tools タブで実行すると、レスポンスがこうなる。

{
  "content": [{ "type": "text", "text": "{ ... }" }],
  "structuredContent": {
    "total": 42,
    "failed": 3,
    "builds": [{ "id": "b-1201", "status": "failure", "durationSec": 87 }]
  }
}

structuredContent が入っていれば成功。tools/list 側にも outputSchema が JSON Schema に変換されて載っているので、そちらも見ておく。

ハマりどころ

1. outputSchema を足した瞬間、全ツール呼び出しが落ちる

既存ツールに outputSchema だけ追加して、ハンドラの returncontent のままにした。結果、こうなった。

MCP error -32603: Tool get_build_stats has an output schema
but no structured content was provided

outputSchema を宣言した時点で、structuredContent の返却は任意ではなく必須になる。SDK がレスポンス生成時に検証していて、無ければツール実行自体がエラーで終わる。

さらに厄介なのが、返した structuredContent がスキーマに合っていない場合も同様に落ちる点だ。よくあるのは以下。

  • API の生レスポンスをそのまま流したら、数値のはずのフィールドが文字列 "42" だった
  • z.string() にしたフィールドが null で返ってきた

回避策: outputSchema の追加とハンドラの修正は必ずセットでやる。外部 API 由来の値を返すなら、返す直前に自分で parse() して境界で弾き、失敗時は isError: true で普通のエラーとして返したほうがデバッグしやすい。

const parsed = OutputShape.safeParse(raw);
if (!parsed.success) {
  return {
    isError: true,
    content: [{ type: "text", text: `形式不正: ${parsed.error.message}` }],
  };
}
return { structuredContent: parsed.data, content: [...] };

2. トップレベルが配列のスキーマは使えない

「ビルドの配列をそのまま返せばいいや」と思って書いたのがこれ。

// これは通らない
outputSchema: z.array(z.object({ id: z.string() }))

outputSchema は JSON Schema の "type": "object" である必要がある。配列・文字列・数値をトップレベルに置くことはできない。SDK の型定義で弾かれることもあれば、クライアント側の検証で落ちることもあり、エラーの出方が一定しないのが地味に厄介だった。

回避策: 必ずオブジェクトで包む。

outputSchema: {
  builds: z.array(z.object({ id: z.string() })),
  total: z.number(),
}

結果的にこのほうが良い。あとから件数やページングカーソルを足すときにスキーマの破壊的変更にならない。

3. 非対応クライアントでは structuredContent が丸ごと無視される

structuredContent は 2025-06-18 で入った比較的新しい機能で、古いクライアントや独自実装のクライアントは読まない。読まないだけでエラーにもならないので、「サーバー側は正しく返しているのに、モデルには何も見えていない」という状態が起きる。

自分の場合、Inspector では期待どおりに見えているのにモデルが「ツールが空を返した」と言い出して、原因特定に時間を取られた。

回避策: content にテキスト版も必ず併記する。仕様上も後方互換のために推奨されている書き方だ。

return {
  structuredContent: result,
  content: [{ type: "text", text: JSON.stringify(result) }],
};

なお、ここで整形して読みやすくしすぎると今度はトークンを食う。件数が多いツールなら、content 側はサマリ 1 行("42 件中 3 件が失敗" 程度)にとどめて、詳細は structuredContent に任せるのが実用的だった。

背景・補足

そもそもなぜ構造化するのか。テキストで JSON を返しても、モデルは大抵ちゃんと読む。それでも outputSchema を書く価値があるのは、呼び出す前にスキーマが分かるからだ。

tools/list の時点で戻り値の形が公開されるので、モデルは「このツールを呼べば failed が取れる」と事前に判断できる。テキストベタ返しだと、一度呼んでみるまで何が返るか分からない。ツールを何段か連鎖させるようなエージェントでは、この差が試行回数に効いてくる。

まとめ

  • outputSchema を宣言したら structuredContent は必須。片方だけ足すと全呼び出しが落ちる
  • トップレベルは必ずオブジェクト。配列は包む(将来のフィールド追加にも効く)
  • 非対応クライアント対策に content のテキスト版を併記する。ただし冗長にしない
  • 外部 API 由来の値は返す直前に safeParse() で検証しておくと、原因がサーバー側かデータ側かすぐ分かる
  • 動作確認は MCP Inspector が最短。tools/listoutputSchema が載っているかも一緒に見ておく
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?