CodeGym /コース /ChatGPT Apps /ツールのサーバー実装:呼び出しから応答まで

ツールのサーバー実装:呼び出しから応答まで

ChatGPT Apps
レベル 4 , レッスン 2
使用可能

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/callresources/listhandshake などはそこに届きます。

典型的なプロジェクト構成:

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 では以下を行います。

  1. MCP サーバーのインスタンスを作成(@modelcontextprotocol/sdk 経由);
  2. ツールを登録(server.registerTool(...));
  3. 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.tsregisterTool を、ビジネスロジックを用いる形に書き換えます。

// 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 サーバーを立ち上げずに suggestGiftsfetchGiftsFromApi に対してユニットテストを書けます。
  • 可読性: ハンドラーはプロトコル(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/outputTemplateopenai/widgetCSPopenai/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 と、わかりやすい errorCodestructuredContent に含める;
  • content には人間向けのメッセージを返す。

例(ツールのメタデータ、つまり titledescriptioninputSchema などは重複を避けるために 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 アプリでの一連の流れにまとめます。

  1. ユーザーが ChatGPT に送ります。
    「友達へのプレゼントを選んで。ボードゲームが好きで、予算は 50 ドルまで」
  2. モデルはツール suggest_gifts とそのスキーマを知っており、これを呼び出すと判断し、tool_call を組み立てます。
    {
      "tool": "suggest_gifts",
      "arguments": {
        "relationship": "friend",
        "maxBudget": 50,
        "interests": ["board games"],
        "age": null
      }
    }
    
  3. プラットフォームはこの JSON-RPC を我々の MCP サーバー(POST /app/mcp)へ送信し、Next.js は本文を server.handle(...) に渡します。
  4. 我々のハンドラー suggest_gifts は次を行います。
    • interests が空でないことを検証;
    • suggestGifts(payload) を呼び出し;
    • GiftItem[] の配列(score 上位 3 件)を受領;
    • それを structuredContent.items に詰め、_meta["openai/outputTemplate"] = "ui://widget/gifts.html" を付与。
  5. ChatGPT は応答を受け取り、structuredContent をコンテキストに入れ、ウィジェットの HTML リソース gifts.html を読み込み、toolOutput を渡します。
  6. 我々の React ウィジェットは window.openai.toolOutput.items を読み取り、ギフト一覧を描画します。モデルは contentstructuredContent を基に、なぜそれらのギフトが適しているかの説明文を書きます。
  7. ユーザーがウィジェットで「もっと見る」を押すと、ウィジェットは 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 の不安定なフォーマット。
今日は itemsidtitleprice を持つ配列なのに、明日は突然 priceamount に改名してウィジェットが落ちる、といったこと。あるいはネストを増やす。変更自体は可能ですが、契約をバージョン管理するか、小さなステップで進化させましょう。そうでないと UI やテストが頻繁に壊れます。

ミス №5: 意味のあるエラー処理がない。
例外を投げて「プラットフォームがどうにかしてくれるだろう」と期待するのはよくありません。モデルはわかりにくい JSON-RPC エラーを受け取り、ユーザーは赤いエラーバナーを見るだけになり、あなたは問題の文脈を失います。isErrorerrorCode、人が読めるメッセージを返し、詳細はサーバーでログ取りするのが良いです。

ミス №6: 認可を無視し、モデルを信じてしまう。
「モデルは賢いから、ユーザーが認可されていなければこのツールは呼ばないだろう」と考えるのは誤りです。モデルはあなたの ACL やリミットを知りません。ツールの説明しか見ていないのです。権限チェックはツール説明に関係なく、必ずサーバーのハンドラーで実施してください。

ミス №7: PII を含むなんでもログに吐いてしまう。
つい input 全体をログしたくなりますが、ChatGPT App では PII(氏名、メール、住所など)を含む恐れがあり、OpenAI のポリシーにも常識にも反します。ログは集約/匿名化した情報に留めましょう(関係性のタイプ、予算の範囲、興味の数など)。

ミス №8: 外部 API 連携でタイムアウトやリトライがない。
ハンドラー内で外部 API に対する fetch をタイムアウトやリトライ無しで行うと、その API の遅延は「ChatGPT が固まった」ように見えます。ユーザーはアプリ全体が壊れたと思うでしょう。サーバー側で時間制限を設け、タイムアウトを処理し、意味のあるエラーを返すべきです。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION