はじめに / 対象と前提
自作 MCP サーバーのツールが「テキストを返すだけ」になっていないだろうか。JSON を渡したいのに、結局 JSON.stringify() した文字列を text で返してモデルにパースし直させている、という状態だ。
MCP の 2025-06-18 仕様で入った outputSchema / structuredContent を使えば、戻り値をスキーマ付きの構造化データとして返せる。TypeScript SDK での実装手順と、実際に踏んだ 3 つのハマりどころをまとめる。
想定読者
- 自作 MCP サーバーを既に 1 本以上動かしている人
- ツールの戻り値がテキストベタ返しになっていて気持ち悪い人
前提環境
- Node.js 22.x
-
@modelcontextprotocol/sdk1.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 だけ追加して、ハンドラの return は content のままにした。結果、こうなった。
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/listにoutputSchemaが載っているかも一緒に見ておく