1. 全体像:サーバー経由でのツール呼び出しの道筋
コードを書く前に、アーキテクチャを固定しておきましょう。細部に溺れないために役立ちます。
Apps SDK + MCP の用語では次のようになります。MCP サーバー(本講義では Next.js の Route Handler、app/mcp/route.ts)がツールやリソースを登録し、それらのツールのハンドラーを実装します。
ハイレベルな図:
sequenceDiagram
participant User as ユーザー
participant Chat as ChatGPT(モデル)
participant App as ChatGPT App
participant MCP as MCPサーバー / バックエンド
participant DB as カタログ/外部API
User->>Chat: "「ギフトを選んで…」"
Chat->>App: ツール `suggest_gifts` を呼び出すことを決定
App->>MCP: JSON-RPC call_tool(名前 + 引数)
MCP->>MCP: バリデーション、認可
MCP->>DB: カタログ照会/フィルタリング
DB-->>MCP: 候補の一覧
MCP-->>App: structuredContent + content + _meta
App-->>Chat: 結果をモデルおよびウィジェットへ受け渡し
Chat-->>User: 選定理由を説明し、ウィジェットを表示
要点:サーバーはモデルの「魔法」を知りません。目にするのは「ツール名 + 引数」という通常のリクエストだけで、構造化された応答を返す義務があります。モデルはあなたのコードを一切見ず、見えるのは次の3つだけです。
- どんなツールがあり、そのスキーマは何か;
- 自分で組み立てた引数;
- あなたが返した JSON の応答。
したがって本講義の目的は、真ん中の部分を丁寧に実装することです。すなわち MCP サーバーとツールのハンドラーです。
Insight: mcp-tools の上限
MCP サーバーにおけるツール数は、メモリやコンテキストトークンと同じく制約のあるメトリクスです。形式上は数十、数百のツールを登録できますが、プラットフォームやモデルは線形には扱いません。新しいツールが増えるほど、ルーティング時の「ノイズ」も増えます。
実務的な目安:
- ハード上限(ChatGPT)≈ サーバーあたり最大 128 MCP-tools;
- 実用レンジ — 最大 50 ツール。これを超えると品質が顕著に落ちます。説明が似たツールを取り違えたり、レアなツールを思い出しづらくなったり、誤った選択をしやすくなります。
Anthropic も似た傾向で、上限は 最大約 100 ツール。彼ら自身も 最大 50 程度に抑えることを推奨しています。
2. Next.js + Apps SDK テンプレートでサーバーロジックがどこにあるか
モジュール2で ChatGPT App 用の公式 Next.js テンプレートを展開し、その構造をざっと確認しました。ここでは MCP サーバーがどこにあり、ウィジェットとどう結びついているかを見ていきます。
このテンプレートを使う場合、MCP サーバーは通常 app/mcp/route.ts(App Router)で実装します。ChatGPT からの JSON-RPC 呼び出し、tools/call、resources/list、handshake などはそこに届きます。
典型的なプロジェクト構成:
my-chatgpt-app/
├─ app/
│ ├─ mcp/
│ │ └─ route.ts # MCPサーバー + ツール登録
│ ├─ page.tsx # React ウィジェット(UI)
│ ├─ layout.tsx # Root layout, Bootstrap SDK
│ └─ globals.css # グローバルスタイル
│
├─ proxy.ts # CORS など
├─ next.config.ts
├─ package.json
├─ tsconfig.json
└─ .env
route.ts では以下を行います。
- MCP サーバーのインスタンスを作成(@modelcontextprotocol/sdk 経由);
- ツールを登録(server.registerTool(...));
- ChatGPT からのリクエストを受け取り MCP サーバーへ渡す HTTP ハンドラーを定義。
この構成を前提に、以降は TypeScript でコードを書いていきます。
3. 最小の MCP サーバーとツールハンドラー
まずは最小構成から始めましょう。学習用のツール suggest_gifts を追加し、ダミーを返すサーバーを作ります。
MCP SDK が既にインストール済みとします。
pnpm add @modelcontextprotocol/sdk
そしてシンプルな app/mcp/route.ts を作成します。
// app/mcp/route.ts
import { NextRequest } from "next/server";
import { McpServer } from "@modelcontextprotocol/sdk/server";
const server = new McpServer({ name: "giftgenius-mcp" });
// 最小スキーマでツールを登録
server.registerTool(
"suggest_gifts",
{
title: "プレゼント選定",
description: "興味と予算に基づいてプレゼントを提案します。",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "贈る相手の簡単な説明。" },
},
required: ["query"],
},
},
async ({ input }) => {
// ここにビジネスロジックを実装
return {
content: [
{
type: "text",
text: `プレースホルダー: 「${input.query}」向けのギフト。`,
},
],
structuredContent: {},
};
}
);
// Next.js の HTTP ハンドラー
export async function POST(req: NextRequest) {
const body = await req.text(); // JSON-RPC 文字列
const response = await server.handle(body);
return new Response(response, {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
これで動作します。ChatGPT は suggest_gifts を呼び出せ、サーバーはテキストのダミーを返します。
重要なのは、server.registerTool が次を受け取る点です。
- ツール名;
- メタデータと入力の JSON Schema;
- ハンドラー — 引数の input が届く非同期関数。
しかし現状では、バリデーションもまともな structured output も、認可もありません。ここから整えていきます。
4. 入力バリデーションとレイヤー分離
JSON Schema だけでは不十分な理由
確かにプラットフォームはスキーマに基づく基本的な検証(型、必須フィールド等)を行います。しかし:
- モデルが論理的に不正なデータを渡すことがあります(例:予算が −100、興味のリストが 1000 件など);
- ビジネス上の制約が存在します(最大予算、対応通貨など);
- ときには ChatGPT や他クライアントが想定外のものを送ることもあります。
したがって、ハンドラー内部でも追加のバリデーションが必要です。
コードの分離:handler ↔ ビジネスロジック
サーバー側のコードがスパゲティ化しないよう、ビジネスロジックは分離しておくのが便利です。例えば app/mcp/gifts.ts を作ります。
// app/mcp/gifts.ts
export type SuggestGiftsInput = {
age?: number | null;
relationship: "friend" | "partner" | "colleague";
maxBudget: number;
interests: string[];
};
export type GiftItem = {
id: string;
title: string;
price: number;
currency: "USD";
score: number;
tags: string[];
shortDescription: string;
};
// 簡単な「ギフト」カタログ
const CATALOG: GiftItem[] = [
{
id: "board-game-1",
title: "ボードゲーム \"宇宙戦略\"",
price: 39,
currency: "USD",
score: 0.93,
tags: ["board_games", "strategy", "2-4_players"],
shortDescription: "ボードゲーム好きに最適なプレゼントです。",
},
// ...
];
export function suggestGifts(input: SuggestGiftsInput): GiftItem[] {
if (input.maxBudget <= 0) {
throw new Error("予算は正の数である必要があります。");
}
const filtered = CATALOG.filter(
(item) => item.price <= input.maxBudget
);
// 簡略化:score でソートして上位 3 件を返す
return filtered.sort((a, b) => b.score - a.score).slice(0, 3);
}
これで MCP ツールのハンドラー側では次の役割に集中できます。
- input の解析;
- SuggestGiftsInput 型へのマッピング;
- suggestGifts の安全な呼び出し;
- ChatGPT と UI が理解できる形式への詰め直し。
5. ハンドラー実装:input から structuredContent へ
route.ts の registerTool を、ビジネスロジックを用いる形に書き換えます。
// app/mcp/route.ts (抜粋)
import { suggestGifts, SuggestGiftsInput } from "./gifts";
server.registerTool(
"suggest_gifts",
{
title: "プレゼント選定",
description:
"興味、予算、関係性のタイプに基づいてプレゼントを提案します。",
inputSchema: {
type: "object",
properties: {
age: {
type: "integer",
minimum: 0,
maximum: 120,
description: "受け取る人の年齢(わかる場合)。",
},
relationship: {
type: "string",
enum: ["friend", "partner", "colleague"],
description: "相手との関係性のタイプ。",
},
maxBudget: {
type: "number",
minimum: 1,
description: "米ドルでの最大予算。",
},
interests: {
type: "array",
items: { type: "string" },
description: "相手の興味(例: board games, hiking)。",
},
},
required: ["relationship", "maxBudget", "interests"],
},
},
async ({ input }) => {
// 基本的な論理バリデーション
if (!Array.isArray(input.interests) || input.interests.length === 0) {
return {
isError: true,
content: [
{
type: "text",
text: "少なくとも 1 つは相手の興味を指定してください。",
},
],
structuredContent: { errorCode: "NO_INTERESTS" },
};
}
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = suggestGifts(payload);
if (items.length === 0) {
return {
content: [
{
type: "text",
text:
"指定の予算内で該当するギフトが見つかりませんでした。予算を増やすか、興味を変更して試してください。",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {
content: [
{
type: "text",
text: `${items.length} 件の適切なギフト候補が見つかりました。`,
},
],
structuredContent: {
items: items.map((item) => ({
id: item.id,
title: item.title,
price: item.price,
currency: item.currency,
shortDescription: item.shortDescription,
tags: item.tags,
})),
},
};
}
);
いくつか重要なポイントがあります。
第一に、interests が空配列でないことを明示的に検証しています。JSON Schema 上は空配列が形式的に許容されていても、我々の用途では意味がありません。ランダムな一覧を組み立てるより、わかりやすいエラーを即返すほうが良いです。
第二に、2 つのデータセットを返しています。
- content — モデル向け。短い要約(「N 件見つかった」)を置きます。モデルはこれをユーザーへの返答に用います。
- structuredContent — モデルと UI 向け。ギフトの一覧を含む構造化 JSON で、ウィジェットがカード表示などに使用できます。
よくある誤りは、content に巨大な JSON を詰め込むことです。これはトークンを浪費し、モデルが混乱する要因になります。content は短く保ち、詳細は structuredContent に入れましょう。
6. UI テンプレートと _meta/openai/outputTemplate を追加
Apps SDK のレベルでは、サーバーがツール結果の可視化に用いる UI テンプレート を ChatGPT に伝えます。これはリソースと _meta["openai/outputTemplate"] を通して行います。サーバーは mimeType が "text/html+skybridge" の HTML リソースを登録し、ツールは応答内でそれを参照します。
Next.js テンプレートでは便利なラッパーに隠されていますが、単純化すると以下の通りです。
// MCP サーバー初期化のどこかで
server.registerResource("ui://widget/gifts.html", {
name: "Gift suggestions widget",
mimeType: "text/html+skybridge",
// 以下:HTML を返す方法(埋め込みテンプレートやファイルなど)
});
ツールの応答では次のようにします。
return {
content: [{ type: "text", text: `ギフトを ${items.length} 件見つけました。` }],
structuredContent: { items: /* ... */ },
_meta: {
"openai/outputTemplate": "ui://widget/gifts.html",
},
};
こうすると ChatGPT は結果の構造を理解するだけでなく、ウィジェット用の HTML/JS を読み込み、iframe 内の React コンポーネントが window.openai.toolOutput を読み取ってギフト一覧を描画できます。
UI 側の詳細は、このモジュール内の「ToolOutput → UI 処理」の講義で扱います。ここでは関連性だけに注目してください。ツールのハンドラーはビジネスデータだけでなく、どの UI テンプレートに結果を紐づけるかも担います。MCP サーバーの視点では、どのテンプレートを指定し、structuredContent に何を入れるか、がポイントです。
Insight
ChatGPT の設計者は、ウィジェットを JSON 表示用のテンプレート として位置づけています。そのため名称として outputTemplate を使っています。設計思想はこうです。ChatGPT が mcp-tool を呼び出し、mcp-tool は JSON を返し、必要に応じてウィジェットも返す。ウィジェットがなければ、ChatGPT は JSON を自分で表示方法を判断します。
ウィジェットが指定されている場合、ChatGPT はウィジェットを表示し、toolOutput として JSON を渡し、ウィジェットがそれを描画します。ウィジェットは JSON 表示のテンプレートです。だからこそ、アプリ登録時の段階で Store にキャッシュされます。
ウィジェットは必要に応じて fetch() を呼び出すなど、自由に使って構いません。ただし、ChatGPT 開発者の当初の考え方を理解しておくと、一部の制約や今後起こり得る変更も受け入れやすくなるでしょう。
7. ハンドラー内での認可とアクセス
これまではすべてが公開データであるかのように振る舞ってきました。しかし実務では、ユーザーのアカウント、注文、支払い、ドキュメントなど、認可を必要とするツールが存在します。
Apps SDK / MCP の用語では、ツールに securitySchemes を設定し、ハンドラー内でトークンやコンテキストを検証します。
最小の例:
server.registerTool(
"list_user_orders",
{
title: "ユーザーの注文一覧",
description: "認可済みユーザーの最新の注文を返します。",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
_meta: {
securitySchemes: [{ type: "oauth2", scopes: ["orders.read"] }],
}
},
async ({ auth }) => {
if (!auth?.accessToken) {
return {
isError: true,
content: [
{
type: "text",
text: "注文を見るにはログインが必要です。",
},
],
_meta: {
// ChatGPT に OAuth UI の起動を依頼
"mcp/www_authenticate": [
'Bearer resource_metadata="https://your-mcp.example.com/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="続行するには認証が必要です。"',
],
},
};
}
// ここで token / issuer / audience / scope を検証
const orders = await fetchUserOrders(auth.accessToken);
return {
content: [
{
type: "text",
text: `${orders.length} 件の最新注文を見つけました。`,
},
],
structuredContent: { orders },
};
}
);
ここで重要なのは次の点です。
- ChatGPT はあなたの検証を「推測」してはくれません。トークンやコンテキストを渡すだけで、正しい認可を実装する責任はサーバー側にあります。
- 特別なフィールド _meta["mcp/www_authenticate"] はプラットフォームに「ログイン/トークン更新の UI をユーザーに表示してほしい」と伝えます。これがないと、ChatGPT は単なるエラーとして扱います。
認可の詳細はモジュール10で扱いますが、ここでは基本概念を押さえましょう。ハンドラーでトークンを検証し、モデルの言い分を鵜呑みにしないことです。
8. 外部 API/DB 連携:レイヤーとプラクティス
「ハンドラーの中で全部やる」誘惑は非常に強いものです。引数のパース、DB への問い合わせ、フィルタリング、structuredContent へのマッピング、ロギング、ちょっとした哲学まで、150 行の関数に全部入り——まるで pages/index.tsx にアプリをすべて書くようなものです。できなくはありませんが、つらいです。
レイヤーを分けるほうが遥かに良いです。
// gifts-repository.ts
import type { GiftItem } from "./gifts";
export async function fetchGiftsFromApi(
maxBudget: number,
interests: string[]
): Promise<GiftItem[]> {
const resp = await fetch("https://example.com/api/gifts", {
method: "POST",
body: JSON.stringify({ maxBudget, interests }),
headers: { "Content-Type": "application/json" },
});
if (!resp.ok) {
throw new Error(`Gift API error: ${resp.status}`);
}
const data = (await resp.json()) as GiftItem[];
return data;
}
// gifts.ts(更新版)
import { fetchGiftsFromApi } from "./gifts-repository";
export async function suggestGifts(input: SuggestGiftsInput): Promise<GiftItem[]> {
if (input.maxBudget <= 0) {
throw new Error("予算は正の数である必要があります。");
}
const items = await fetchGiftsFromApi(input.maxBudget, input.interests);
return items.sort((a, b) => b.score - a.score).slice(0, 3);
}
// route.ts(ハンドラーの抜粋)
async ({ input }) => {
try {
const payload: SuggestGiftsInput = {
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
};
const items = await suggestGifts(payload);
// ...
} catch (err) {
console.error("suggest_gifts failed", err);
return {
isError: true,
content: [
{
type: "text",
text: "ギフトの提案中にエラーが発生しました。しばらくしてからもう一度お試しください。",
},
],
structuredContent: {
errorCode: "INTERNAL_ERROR",
},
};
}
}
このアプローチにはいくつかの利点があります。
- テスト容易性: MCP サーバーを立ち上げずに suggestGifts や fetchGiftsFromApi に対してユニットテストを書けます。
- 可読性: ハンドラーはプロトコル(MCP)とロジックの薄いアダプターに留まります。
- 再利用性: 後に同じギフト提案を別の場所(例えば別の REST API)で使いたくなっても、MCP からロジックを「引き剥がす」必要がありません。
9. ロギングと基本的な可観測性
ツールのサーバー実装は、最小限の可観測性を最初から入れておく好機でもあります。本番では次のことを把握したくなります。
- どのツールが呼ばれているか;
- どんな引数か(もちろん PII は除く);
- 処理にどれくらい時間がかかるか;
- どんなエラーがどれくらい発生しているか。
今回は ChatGPT App の仕組み理解が主題なので、本格的なロガーは後回しにします。ハンドラーの周りに最小のラッパーロガーを置くと、例えば次のようになります。
// simple-logger.ts
export function logToolInvocationStart(tool: string, args: unknown) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_started",
tool,
timestamp: new Date().toISOString(),
// 本番で PII をログに記録しないでください!
args,
})
);
}
export function logToolInvocationEnd(tool: string, ms: number, success: boolean) {
console.log(
JSON.stringify({
level: "info",
event: "tool_invocation_finished",
tool,
durationMs: ms,
success,
timestamp: new Date().toISOString(),
})
);
}
// route.ts(ハンドラーのラッパー)
import { logToolInvocationStart, logToolInvocationEnd } from "./simple-logger";
server.registerTool(
"suggest_gifts",
{ /* ...meta... */ },
async ({ input }) => {
const startedAt = Date.now();
logToolInvocationStart("suggest_gifts", {
relationship: input.relationship,
maxBudget: input.maxBudget,
interestsCount: Array.isArray(input.interests)
? input.interests.length
: 0,
});
try {
// ... 主要ロジック ...
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, true);
return result;
} catch (err) {
const duration = Date.now() - startedAt;
logToolInvocationEnd("suggest_gifts", duration, false);
throw err;
}
}
);
後のモジュールでメトリクス、SLO、監視を扱う際に、これらのログを基にグラフやアラートを構築できます。とはいえ、今のうちからロギングの習慣をつけておくと良いでしょう。
10. サーバーの結果がウィジェットに届くまで(とその逆)
セクション6で、_meta["openai/outputTemplate"] を使ってツールの結果を UI テンプレートに紐づけました。ここでは別角度から、structuredContent が React ウィジェット内にどう渡され、UI で何をするかを見ます。
この講義はサーバーに焦点を当てていますが、設計しているのは「モデル向けの API」だけでなく「UI 向けの API」でもあることが大切です。サーバーは次を返します。
- structuredContent — モデルとウィジェット(toolOutput 経由)が見るデータ;
- content — モデル向けの「圧縮」された結果説明;
- _meta — ウィジェット向けのプライベートなフィールド(openai/outputTemplate、openai/widgetCSP、openai/widgetDomain など)。
React ウィジェット内では次のようにします。
// app/page.tsx (抜粋)
type ToolOutput = {
items?: {
id: string;
title: string;
price: number;
currency: string;
shortDescription: string;
tags: string[];
}[];
emptyReason?: string;
};
declare global {
interface Window {
openai?: {
toolOutput?: ToolOutput;
};
}
}
export default function GiftWidget() {
const output = typeof window !== "undefined"
? window.openai?.toolOutput
: undefined;
if (!output) {
return <div>ギフトの提案結果を待機中…</div>;
}
if (!output.items || output.items.length === 0) {
return <div>該当するギフトがありません。条件を変更してお試しください。</div>;
}
return (
<ul>
{output.items.map((item) => (
<li key={item.id}>
<strong>{item.title}</strong> — {item.price} {item.currency}
</li>
))}
<ul>
);
}
だからこそ、structuredContent は安定した契約であり、UI フレンドリー(個別のフィールドで、10 階層ものネスト地獄ではない)であることが非常に重要です。
この流れの詳細はモジュール4の別講義で扱いますが、ここでは、サーバーとウィジェットが同じ構造の structuredContent に依拠していることを確認しておきます。
11. サーバーでのエラー処理:フォーマットと戦略
セクション 8–9 では、ハンドラー内のエラーとロギングに少し触れました。ここで統一フォーマットをまとめます。ツールのエラーを、モデルと UI の双方が扱いやすいようにどう返すべきかです。
ハンドラーでエラーは避けられません。外部 API が落ちたり、悪い input が来たり、単なるタイプミスもありえます。大切なのは、モデルやユーザーに「説明のない 500 Internal Server Error」を渡さないことです。
良いサーバー実装のツールは次を満たします。
- ユーザー/モデルの入力バリデーションエラーと内部エラーを区別する;
- isError と、わかりやすい errorCode を structuredContent に含める;
- content には人間向けのメッセージを返す。
例(ツールのメタデータ、つまり title、description、inputSchema などは重複を避けるために meta 変数にすでに退避していると仮定):
function makeErrorResult(message: string, code: string) {
return {
isError: true,
content: [
{
type: "text",
text: message,
},
],
structuredContent: {
errorCode: code,
},
};
}
server.registerTool(
"suggest_gifts",
meta,
async ({ input }) => {
try {
if (input.maxBudget > 10000) {
return makeErrorResult(
"予算が大きすぎます。上限(10000 USD)内で指定してください。",
"BUDGET_TOO_HIGH"
);
}
const items = await suggestGifts({
age: input.age ?? null,
relationship: input.relationship,
maxBudget: input.maxBudget,
interests: input.interests,
});
if (!items.length) {
return {
content: [
{
type: "text",
text:
"この予算ではギフトが見つかりませんでした。興味を変えるか、予算を増やしてみてください。",
},
],
structuredContent: {
items: [],
emptyReason: "NO_MATCHES",
},
};
}
return {/* 正常結果 */};
} catch (err) {
console.error(err);
return makeErrorResult(
"ギフトの提案中にサーバー内部エラーが発生しました。",
"INTERNAL_ERROR"
);
}
}
);
このフォーマットはモデルにも UI にも有益です(モデルは引数の調整を試みられ、ウィジェットは errorCode に応じた特定メッセージを表示できます)。
堅牢性、冪等性、安全なツール設計については後続の講義で詳しく扱いますが、ここでもう習慣化しましょう。変なことを黙ってやるより、明示的にエラーを返すほうが良いのです。
最後に、ツールのサーバー実装でよくあるミスをチェックリストとしてまとめます。
12. 簡単なエンドツーエンド例:リクエストから応答まで
ここまで作ったものを、私たちの GiftGenius アプリでの一連の流れにまとめます。
- ユーザーが ChatGPT に送ります。
「友達へのプレゼントを選んで。ボードゲームが好きで、予算は 50 ドルまで」 - モデルはツール suggest_gifts とそのスキーマを知っており、これを呼び出すと判断し、tool_call を組み立てます。
{ "tool": "suggest_gifts", "arguments": { "relationship": "friend", "maxBudget": 50, "interests": ["board games"], "age": null } } - プラットフォームはこの JSON-RPC を我々の MCP サーバー(POST /app/mcp)へ送信し、Next.js は本文を server.handle(...) に渡します。
- 我々のハンドラー suggest_gifts は次を行います。
- interests が空でないことを検証;
- suggestGifts(payload) を呼び出し;
- GiftItem[] の配列(score 上位 3 件)を受領;
- それを structuredContent.items に詰め、_meta["openai/outputTemplate"] = "ui://widget/gifts.html" を付与。
- ChatGPT は応答を受け取り、structuredContent をコンテキストに入れ、ウィジェットの HTML リソース gifts.html を読み込み、toolOutput を渡します。
- 我々の React ウィジェットは window.openai.toolOutput.items を読み取り、ギフト一覧を描画します。モデルは content と structuredContent を基に、なぜそれらのギフトが適しているかの説明文を書きます。
- ユーザーがウィジェットで「もっと見る」を押すと、ウィジェットは SDK 経由で callTool を呼び出し、再び我々のハンドラーに到達します(例えば予算を増やすなど、別の引数で)。
この一連の流れは、ツールのサーバー実装が次を満たしていることに依存しています。
- 合意した JSON Schema に基づく構造化 input を受け取る;
- データを丁寧にバリデートする;
- 分離されたビジネスロジックを呼び出す;
- 安定した structured output を返す;
- 必要に応じて UI テンプレートとメタデータを指定する。
13. ツールのサーバー実装でありがちなミス
ミス №1: 「全部ひとまとめ」な巨大ハンドラー。
すべてのロジックと外部 API 連携を server.registerTool(..., async () => { ... }) の中に詰め込むと、コードはすぐに肥大化して読みにくいモノリスになります。些細な変更で全体が壊れます。ビジネスロジックは別モジュール/関数に切り出し、ハンドラーは薄いアダプターにしましょう。
ミス №2: JSON Schema を盲信する。
「スキーマがあるから入力は常に正しい」と考えがちですが、モデルは奇妙な値を送ることがありますし、外部クライアントはなおさらです。型や JSON Schema のみに頼らず、論理バリデーション(予算の範囲、配列の長さ、許可された値など)も必要です。
ミス №3: すべてを content に詰め込み、structuredContent を無視する。
content に巨大な JSON を文字列で「念のため」入れるケースがあります。これはモデルのプロンプトをノイジーかつ高コストにし、UI では文字列をデコードしなければならず不便です。content は短く、詳細は structuredContent に置きましょう。
ミス №4: structured output の不安定なフォーマット。
今日は items が id、title、price を持つ配列なのに、明日は突然 price を amount に改名してウィジェットが落ちる、といったこと。あるいはネストを増やす。変更自体は可能ですが、契約をバージョン管理するか、小さなステップで進化させましょう。そうでないと UI やテストが頻繁に壊れます。
ミス №5: 意味のあるエラー処理がない。
例外を投げて「プラットフォームがどうにかしてくれるだろう」と期待するのはよくありません。モデルはわかりにくい JSON-RPC エラーを受け取り、ユーザーは赤いエラーバナーを見るだけになり、あなたは問題の文脈を失います。isError と errorCode、人が読めるメッセージを返し、詳細はサーバーでログ取りするのが良いです。
ミス №6: 認可を無視し、モデルを信じてしまう。
「モデルは賢いから、ユーザーが認可されていなければこのツールは呼ばないだろう」と考えるのは誤りです。モデルはあなたの ACL やリミットを知りません。ツールの説明しか見ていないのです。権限チェックはツール説明に関係なく、必ずサーバーのハンドラーで実施してください。
ミス №7: PII を含むなんでもログに吐いてしまう。
つい input 全体をログしたくなりますが、ChatGPT App では PII(氏名、メール、住所など)を含む恐れがあり、OpenAI のポリシーにも常識にも反します。ログは集約/匿名化した情報に留めましょう(関係性のタイプ、予算の範囲、興味の数など)。
ミス №8: 外部 API 連携でタイムアウトやリトライがない。
ハンドラー内で外部 API に対する fetch をタイムアウトやリトライ無しで行うと、その API の遅延は「ChatGPT が固まった」ように見えます。ユーザーはアプリ全体が壊れたと思うでしょう。サーバー側で時間制限を設け、タイムアウトを処理し、意味のあるエラーを返すべきです。
GO TO FULL VERSION