1. はじめに
ChatGPT App HelloWorld プロジェクトは「CodeGym の魔法のブラックボックスで、触らない方がよい」ものではありません。これは普通の Next.js プロジェクトであり、同じリポジトリ内に次のものが同居しています。
- ChatGPT 内でレンダリングされるフロントエンド、
- ツール(tools)呼び出しに応答する MCP サーバー、
- それらすべてを ChatGPT と結び付ける設定。
どこに何があるかを理解していないと、典型的に次の3つのシナリオが発生します。
- 開発者が誤ってサーバーファイル内で window を書いてしまい、クラッシュしてスタック全体を嫌いになってしまう。
- UI にボタンを追加しようとして、正しくない page.tsx(たとえばアプリのルートであってウィジェットではない)を修正し、ChatGPT 側で変更が見えない。
- 誤ってクライアント側に OPENAI_API_KEY を置いてしまい、キーがブラウザに漏れてしまう。
そこで、今日の目標は「地図にマーキングする」ことです。UI はどこか、MCP はどこか、コンフィグはどこか、そして次のようなことをしたいときにどこを触ればよいかを整理します。
- ウィジェットの見た目を変える;
- 新しい tool を追加する;
- プラットフォーム設定(CORS、assetPrefix など)を調整する。
2. プロジェクトの高レベルな構造
ChatGPT App HelloWorld の Next.js プロジェクトは App Router を使い、app/ フォルダを中心に構成されています。ひとつのページツリーの中に次のものが共存します。
- ChatGPT 内でレンダリングされるウィジェットの UI、
- tool 呼び出しを処理する MCP エンドポイント。
典型的なツリー(簡略版。あなたのテンプレートではフォルダ名が異なることがありますが、パターンは同じです)。
my-chatgpt-app/
├─ app/
│ ├─ api/ // REST API
│ │ └─ time/ // GET /api/time はサーバーの時刻を返す
│ │ └─ route.ts
│ ├─ hooks/ // 公式 Apps SDK のフック群
│ │ ├─ use-call-tool.ts
│ │ ├─ use-display-mode.ts
│ │ └─ use-open-external.ts
│ ├─ mcp/ // MCP サーバー: ChatGPT が tools を呼ぶときにアクセスされる
│ │ └─ route.ts
│ ├─ globals.css // アプリ全体のルート globals.css
│ ├─ layout.tsx // アプリ全体のルート layout
│ └─ page.tsx // ChatGPT 内のウィジェットページ
├─ public/ // 静的ファイル: アイコン、マニフェストなど
├─ next.config.ts // Next.js の設定と Apps 特有の設定(assetPrefix など)
├─ proxy.ts // iframe 内で動かすための CORS/ヘッダー(旧 middleware.ts)
├─ package.json // プロジェクトの依存関係
├─ tsconfig.json // TypeScript 設定
└─ .env.local // シークレット: OPENAI_API_KEY など
ウィジェットが複数ある場合、通常は app/page.tsx ではなく app/widget/page.tsx に置きます。ただしロジックは変わりません。ページ(ウィジェット)はひとつ、MCP サーバー役のエンドポイントもひとつです。
考え方としては、リポジトリは「二つの顔を持つヤヌス」のようなものです。
- 一つ目の「顔」は /mcp というパス。ChatGPT がツールを呼びたいときに向かう先です。
- 二つ目の「顔」は /widget(または /)というパス。モデルがあなたの UI を表示すると決めたとき、iframe にロードされます。
混乱を避けるため、頭の中で次の3つのファイル群を固定しておきましょう。
- UI レイヤー — React/Next のページに関するすべて (app/widget、コンポーネント、スタイル)。
- MCP レイヤー — app/mcp/route.ts と、その中で使われるファイル。
- 接着レイヤーとコンフィグ — next.config.ts、 proxy.ts、.env.local、 package.json、tsconfig.json。
以下では、これら各レイヤーを順に見ていきます。
3. ウィジェットがある場所: フォルダ app/widget および/または app/page.tsx
まず最も頻繁に触るであろう「ウィジェット」、つまり ChatGPT 内に表示される UI から始めましょう。
最近の多くのプロジェクトでは次のいずれかになっています。
- app/widget/page.tsx フォルダ — ウィジェットは /widget という専用プレフィックスで配置、
- あるいはルートの app/page.tsx — ウィジェットがルートページと一致。
ウィジェットファイルの主な特徴:
- 先頭に 'use client' がある。コンポーネントはブラウザで動き、 window や Apps SDK とやり取りするため。
- これは通常の React コンポーネントで、マークアップをレンダリングし(コースの後半で)window.openai とやり取りします。
最もシンプルな学習用ウィジェットの例(あなたのプロジェクトにもよく似たものがあるはずです)。
// app/widget/page.tsx
'use client';
import React from 'react';
export default function WidgetPage() {
return (
<main className="p-4">
<h1 className="text-xl font-semibold">
HelloWorld — ChatGPT App
</h1>
<p className="text-sm text-gray-500">
ここにウィジェットの UI を作っていきます。
</p>
</main>
);
}
テンプレート内でウィジェットが app/page.tsx に直置きされている場合でも、コードはほぼ同じで、 中間の widget フォルダがないだけです。
いくつかのポイントに注意してください。
第一に、'use client' ディレクティブは必須です。 ウィジェットは window.openai を読み書きし、 イベントを購読するなどの処理を行います。これはクライアントコンポーネントでのみ可能です。 これを外すと、Next はページをサーバー側で処理しようとし、「window is not defined」のようなエラーになります。
第二に、これはまったく「魔法」ではない普通の React コンポーネントです。たとえば:
- components/ に分割してサブコンポーネント化する、
- Tailwind など任意の CSS システムを使う、
- コンテキストやフックを導入する、など。
第三に、後でまさにここで次のことを行います。
- window.openai.toolInput と window.openai.toolOutput を読み取り、実データを描画する。
- widgetState を window.openai.setWidgetState 経由で保存する。
- openExternal、callTool などのランタイムメソッドを呼び出す。
いまの段階では次だけ覚えておけば十分です。見た目(UI)を変えたいなら、ほぼ間違いなく app/widget/page.tsx か app/page.tsx を触ります。
4. ルート layout: アプリ全体の「額縁」としての app/layout.tsx
次に重要なのが app/layout.tsx です。これには次の役割があります。
- HTML 構造(<html>、<body>)を定義する、
- グローバルスタイル(globals.css)を読み込む、
- よく Apps SDK の「ブートストラップ」(window.openai を監視してデータを React に渡すラッパー)を初期化する。
簡略例:
// app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';
import { OpenAIAppProvider } from '@/lib/openai-app-provider';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<NextChatSDKBootstrap baseUrl={baseURL} />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`}>
{children}
</body>
</html>
);
}
ここでの NextChatSDKBootstrap という名前は仮のもので、テンプレートによっては OpenAIAppProvider など別名かもしれません。一般にその役割はひとつで、React ツリーと Apps SDK ランタイムの橋渡しをし、 グローバルデータ(theme、displayMode、toolInput など)を購読して子コンポーネントに配布します。
実務上の重要なポイント: グローバルなコンテキスト、スタイル、UI ライブラリ(例: shadcn/ui)を組み込みたい場合、 その場所はたいてい app/layout.tsx(またはウィジェット特有の設定/コンポーネントなら app/widget 配下の layout)になります。
NextChatSDKBootstrap の解剖
NextChatSDKBootstrap は Vercel の公式テンプレートからヒントを得ています。 ご存じない方のために言うと、Next を作り、開発しているのが彼らです。公式サイトには Next 上で動く ChatGPT App に関する良記事があります。また、Starter Template も提供されています。 いくつか古くなっている点はあるものの、今後も最新に保たれる可能性は高いと思います。
NextChatSDKBootstrap が与えてくれる重要ポイントを5つ挙げます。
- 1. ハイドレーション問題の解消
ChatGPT はまずあなたのウィジェットの HTML を自分たちのサーバーで読み込み、整形・パッチします。 その結果、ハイドレーションの仕組みが警告を出してコンソールが Warnings で溢れることがあります。これは審査(review)通過の妨げになります。 - 2. ブラウザ履歴のパッチ
ウィジェットは ChatGPT の特別なドメイン上の iframe で読み込まれます。 自分のドメインを使うとサンドボックスを壊してしまうため、ブラウザ履歴にはドメインを除いたパスのみを保存します。 - 3. fetch() の差し替え
ドメインのない相対パスへの fetch() は、ウィジェット内では動作しません。 iframe のドメインが異なるためです。そこで fetch() を差し替え、 ドメインなしのリクエストを正しい URL に送るようにします。ドメインが指定されていれば従来通り動作します。 - 4. リンククリックの動作保証
リンクが iframe 内で開くと、ChatGPT はそれを好みません。 そのため、リンククリックを監視し、外部ウィンドウで開くように openExternal() を使います。 - 5. head base の設定(DEPRECATED)
かつては <base> を <head> に追加していましたが、 これは機能しなくなりました。サンドボックスがどんな base もリセットしてしまうため、 スクリプト、リソース、フォント、API などすべてに絶対 URL を使うことを推奨します。
5. MCP サーバー: app/mcp/route.ts
では「二つの顔」のもう半分、MCP で ChatGPT と対話するサーバーに移りましょう。
app/mcp/route.ts は、一般的な App Router の Route Handler で、次のことを行います。
- ChatGPT からの HTTP リクエスト(通常は JSON ペイロードの POST、形式は MCP)を受け付ける、
- MCP サーバー(@modelcontextprotocol/sdk あるいは薄いラッパー)に渡す、
- MCP 形式の JSON レスポンスを返す。
やり方は2通りあります。素の MCP SDK で書くか、Next/Vercel のいくつかのクラスを使って角を丸めるかです。
こちらは素の TS MCP SDK の例です。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
// 1. MCP サーバーの作成
const server = new McpServer({
name: "simple-mcp-server",
version: "1.0.0",
});
// 2. MCP Resources を登録
// 3. MCP Tools を登録
// 4. HTTP トランスポート
const transport = new HttpServerTransport({
port: 3001,
path: "/mcp",
});
// 5. サーバー起動
await server.connect(transport);
ただし、いくつかの用意済みクラスを使うと、より快適に開発できます。
// app/mcp/route.ts
import { NextRequest } from 'next/server';
import { createMcpHandler } from "mcp-handler";
const handler = createMcpHandler(async (server) => {
const gateway = new McpGateway(server);
await gateway.initialize();
gateway.registerResources();
gateway.registerTools();
});
export const GET = handler;
export const POST = handler;
ここでの McpGateway は McpServer を包むラッパークラスで、 SDK を使って(たとえば lib/mcp/server.ts などで)作成します。 今回はすべてを app/mcp/route.ts に収めています。このファイルの中身を丸ごと分解していきましょう。
type ContentWidget
ファイルの冒頭で ContentWidget 型を定義します。これはウィジェットの全データを保持し、2つの場面で使います。 すなわち、ウィジェットを mcp-resource として登録するとき、および mcp-tool がメタデータを返すとき(返却データを表示するのにどのウィジェットを使うか指定)です。
type ContentWidget = {
id: string; // 一意の名前/キー
title: string; // タイトル
description: string; // 説明
templateUri: string; // ウィジェットの一意な URI。任意。挙動には影響しない。
invoking: string; // 読み込み中にウィジェット上部に出すメッセージ
invoked: string; // 読み込み完了後にウィジェット上部に出すメッセージ
html: string; // ウィジェットの HTML コード全体
widgetDomain: string; // ウィジェットの「ドメイン」。挙動への影響はない。
};
class McpGateway
McpServer のラッパークラスで、いくつかの操作を簡略化します。6つのメソッドを持ちます。
- initialize() — ウィジェットの HTML を読み込む
- registerResources() — ウィジェットを mcp-resources として登録
- registerTools() — 関数を mcp-tools として登録
- widgetMeta() — ウィジェットのメタデータを返す
- getAppsSdkCompatibleHtml() — ウィジェットの HTML を読み込み、軽くパッチする
- makeImgUrlsAbsolute() — HTML 内の画像 URL を絶対パスに書き換える
それぞれ詳しく見ていきます。
public async initialize()
このメソッドは、インターネットからウィジェットの HTML を取得し、ContentWidget 型のオブジェクトを埋めます。
{
id: "hello_world", // ウィジェットの一意キー
templateUri: "ui://widget/hello_world.html", // ウィジェットの一意 URI。"ui:" 自体には意味はない
title: "HelloWorld Widget", // ウィジェット名
description: "Displays the HelloWorld widget", // ウィジェットの動作を LLM に説明
invoking: "Loading widget...", // 読み込み中に出すメッセージ
invoked: "Widget loaded", // 読み込み完了後に出すメッセージ
html: htmlWidget, // ウィジェットの HTML
widgetDomain: baseURL, // ウィジェットの「ドメイン」。現状は影響なし
}
public registerResources()
ウィジェットを mcp-resources として登録します。 server.registerResource() を呼び、4 つの引数を渡します。
- MCP リソースの id/キー
- リソースの URI(MCP プロトコル上の必要事項。ウィジェットにとっては実質ユニークアドレスの同義)
- MCP リソースのメタデータ
- MCP リソースを返す関数
ウィジェットのメタデータ
{
title: widget.title, // リソース/ウィジェット名
description: widget.description, // リソース/ウィジェットの説明
mimeType: "text/html+skybridge", // 重要!この MIME の HTML だけがウィジェットとして表示される
_meta: {
"openai/widgetDescription": widget.description, // ウィジェットの説明
"openai/widgetPrefersBorder": true, // ChatGPT にウィジェットの枠線表示を指示
},
}
MCP リソースとしてのウィジェット
{
uri: uri.href, // URI(引数 uri から取得)
mimeType: "text/html+skybridge", // 重要!この MIME の HTML だけがウィジェットとして表示される
text: widget.html, // ウィジェットの HTML
_meta: {
"openai/widgetDescription": widget.description, // ウィジェットの説明
"openai/widgetPrefersBorder": true, // ウィジェットの枠線表示
"openai/widgetDomain": widget.widgetDomain, // ウィジェットの「ドメイン」。現状は影響なし
"openai/widgetCSP": { // 重要!ウィジェットから到達可能なドメイン
connect_domains: [ // 接続用ドメイン(fetch など)
baseURL,
"https://codegym.cc",
],
resource_domains: [ // リソース用ドメイン(css/fonts/img)
baseURL,
"https://codegym.cc",
"https://cdn.tailwindcss.com",
"https://persistent.oaistatic.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com"
]
}
},
}
将来的に openai/widgetCSP は何度も取り上げますが、ここでは 2 点だけ押さえておきましょう。
- connect_domains — 次のためのドメイン一覧:
- fetch()
- スクリプトの読み込み
- openExternal()
- resource_domains — 次のためのドメイン一覧:
- 画像
- CSS
- フォント
理論上は 200 ドメイン書けますが、そのリストでレビューを通るかは別問題です。
すでに公開済みのアプリのパラメータも調べましたが、amplitude.com が含まれていました。 これもポジティブな材料です。良質なアナリティクスは誰にとっても有益でしょう。
public registerTools()
関数を mcp-tools として登録します。 server.registerTool() を呼び、3 つの引数を渡します。
- MCP tool の id/キー
- MCP tool のメタデータ
- MCP tool を返す関数
ツールのメタデータ
以下の各パラメータはどれも重要です。詳細は次回以降の講義で扱います。
{
title: widget.title, // ツール名
description: "Returns HelloWorld widget", // 重要!ツールが何をするかの説明
inputSchema: z.object({}).describe("No inputs"), // ツールの入力スキーマ。Zod も可
_meta: this.widgetMeta(widget), // ウィジェットのメタデータ(どのウィジェットを表示するか)
annotations: {
destructiveHint: false, // 重要度の高い操作なら confirm が必要
openWorldHint: false, // サードパーティサービスに影響を与える操作
readOnlyHint: true // 状態を変更しない(読み取り専用)
},
}
なにか重要な処理を行う関数
async (input, extra) => {
// 1. パラメータのバリデーション
// 2. 重要な処理を実行
return {
content: [{ type: "text", text: "HelloWorld MCP-tool" }], // 結果の説明(LLM 向け)
structuredContent: { // 重要!これが結果の JSON
timestamp: new Date().toISOString() // 任意のデータを含められる
},
_meta: this.widgetMeta(widget), // JSON を表示するウィジェットのメタデータ
}; // 省略可。省略時はウィジェットは表示されない
}
private widgetMeta(widget: ContentWidget)
ウィジェットのメタデータを返します。ChatGPT はこれに基づいて、JSON 結果を表示するためにどのウィジェットを使うかを決めます。
{
"openai/outputTemplate": widget.templateUri, // ウィジェットの URI
"openai/toolInvocation/invoking": widget.invoking, // 読み込み中に表示するメッセージ
"openai/toolInvocation/invoked": widget.invoked, // 読み込み完了後に表示するメッセージ
"openai/widgetAccessible": true, // ウィジェットから MCP tool を呼び出せる
"openai/resultCanProduceWidget": true, // MCP tool がウィジェットを返す
}
とくに "openai/outputTemplate" について一言。 MCP プロトコルには 3 つのエンティティ(詳細はモジュール 6 で)があります。
- MCP Resources
- MCP Templates
- MCP Tools
この "openai/outputTemplate" は、MCP Templates とは無関係です。 ChatGPT Apps では MCP Templates は使用されません。ここでの「template」という言葉の由来は次の通りです。
ウィジェットは JSON 表示のためのテンプレートとして考案されました。MCP tool が JSON を返すと、LLM はウィジェットを表示し、 ToolOutput パラメータで JSON を渡します。ウィジェットはそれを美しく表示します。outputTemplate はウィジェットの同義語にすぎません。
ひとまずここまで。これらの詳細はモジュール 4 で、ツールの定義方法、JSON Schema、ハンドラの実装とともに扱います。 いま重要なのは、ツール(tools)やロジックに関するものは app/mcp/route.ts の近くを探せば見つかる、という理解です。
6. コンフィグと「接着剤」: next.config.ts、middleware.ts、.env など
ここでは、Next.js プロジェクトを ChatGPT の iframe 内で正しく動かし、 HTTPS トンネル(ngrok、Cloudflare Tunnel など。トンネルについては別途扱います)経由で ChatGPT からアクセス可能にするために必要な主要ファイル群を見ていきます。
next.config.ts
このファイルでは、標準的な Next.js 設定に加えて次のようなものを設定することが多いです。
- assetPrefix — 静的アセット(/_next/ 配下の JS、CSS)を ChatGPT のドメインではなく、あなたの開発用 URL(トンネルや Vercel)から正しく読み込むため。
- テンプレート特有の設定(例: Next 16 の実験的フラグ)
実際には、必要なフィールドを持つ nextConfig をエクスポートする形になります。 講義のポイントはひとつ。ChatGPT 内でウィジェットが CSS/JS を読み込めない場合、その原因はたいてい assetPrefix にあります。
proxy.ts(旧 middleware.ts)
このファイルは、ChatGPT からのリクエストとあなたのルートの間にミドルウェア層を挟みます。テンプレートでは通常次のことを行います。
- iframe の ChatGPT からあなたのサーバーにアクセスできるよう、CORS ヘッダーを設定する、
- 場合によっては React Server Components 用の追加ヘッダーを設定する。
すべての詳細を今理解する必要はありません。覚えておくとよいのは次の点です。 ChatGPT が CORS を理由に失敗したり、DevTools にアクセス拒否のエラーが出ているなら、 proxy.ts を確認しましょう。
.env
.env(または .env.local)は、シークレットや環境変数を置く場所です。
- OPENAI_API_KEY(MCP サーバーが OpenAI API に自分でアクセスする場合)
- 内部 API のアドレス
- サードパーティサービスのトークンなど
重要な注意点として、Next.js では NEXT_PUBLIC_ で始まる変数は自動的に JS バンドルに含まれ、ブラウザから参照可能になります。 OPENAI_API_KEY でこれを絶対にやってはいけません。シークレットはサーバー側の変数だけにしましょう。
package.json と tsconfig.json
package.json では次のようなものが見つかります。
- Next.js、React、Apps SDK、MCP SDK などの依存関係のバージョン、
- dev、build、start のスクリプト、場合によっては補助コマンド(リンタ、フォーマッタなど)。
tsconfig.json には、馴染みのある TypeScript 設定があります。
- エイリアスのパス(@/lib、@/components)、
- strict モード、
- コンパイルのターゲット。
本講座の観点では、このテンプレートは標準的な TypeScript スタックを使っており、通常のやり方で拡張できるという点が最重要です。
7. 開発者のための「クイックナビ」
典型的なタスクをやりたいとき、どこを触るべきかをミニシナリオ形式で固定しておきましょう(箇条書きではなく簡潔に)。
ウィジェットのテキスト/ボタンを変更したいときは、UI ウィジェットのファイルを開きます。 それはテンプレートに応じて app/widget/page.tsx または app/page.tsx のどちらかです。 そこで JSX を修正し、新しいコンポーネントを追加し、デザインシステムを組み込みます。ここで Apps SDK ランタイム (window.openai または便利なフック)を使ってデータを表示します。
サーバー側で何かを実行する新しいボタンを追加したい場合でも、まずは UI ファイルから始めます。 ウィジェットのボタンはクリックで window.openai.callTool を呼びます。ツールの実装は、app/mcp/route.ts 付近の MCP サーバーのコードに追加します。 UI ↔ tool ロジックの結び付けはモジュール 4 以降で扱います。
ChatGPT に新しい機能(例: 「ツアー検索」「商品のレコメンド」)を教えたいときは、 MCP レイヤー(app/mcp/route.ts からインポートされるファイル群)に進みます。 そこで JSON Schema、説明、ハンドラを備えた新しい tool を登録します。ウィジェットはその結果を window.openai.toolOutput で読み取り、見栄えよく表示できます。
ローカルでは平気なのに ChatGPT 内だと静的ファイルが壊れる、あるいはウィジェットの表示が変な場合は、 接着レイヤーを思い出してください。まずは next.config.ts (とくに assetPrefix)および middleware.ts/proxy.ts(CORS)を確認します。 直近でトンネル、URL の変更や Vercel へのデプロイを行ったなら、これらの設定の正しさがとても重要です。
キーや環境変数の問題を疑っているなら、注目すべきは .env.local、package.json (実際に使われている依存関係とスクリプトの確認)、そして開発サーバーのログです。 この 3 点が、MCP が必要なシークレットやサービスにアクセスできるかどうかを左右します。
8. ミニ実習: 実際にファイルシステムをたどってみる
理屈だけでなく、手を動かして配置を確認しておきましょう。以下のステップはエディタ/IDE ですぐに試せます。
プロジェクトで app フォルダを開き、どのファイルがウィジェットを担当しているか探してみましょう。 テンプレートが app/page.tsx を使っている場合、そこに 「HelloWorld — ChatGPT App」などの見慣れた文言や挨拶テキストがあるはずです。もしウィジェット用のフォルダが見当たらなければ、 app/page.tsx を開いて、'use client' と何らかの JSX マークアップがあることを確認してください。
次に app/mcp/route.ts を探します。どのモジュールをインポートしているかに注目してください。 直接 MCP SDK を使っているか、lib/mcp/* のユーティリティ関数を呼んでいるはずです。 この薄い層がどの程度「薄い」かを評価してみましょう。理想的にはビジネスロジックがほとんどなく、 「JSON を受け取る → サーバーに渡す → JSON を返す」だけです。
その後、next.config.ts と proxy.ts/middleware.ts を覗きます。 すべてを理解する必要はありません。次の点だけ押さえておいてください。
- next.config.ts は Next の設定(ビルドとアセット配信のルールを含む)を司る。
- proxy.ts は HTTP リクエストを介入し(ほぼ確実にヘッダー操作が見えるはず)、必要な調整を行う。
最後に .env または .env.local を開き、 キーがコード内ではなくそこに置かれていることを確認してください。もしどこかに NEXT_PUBLIC_OPENAI_API_KEY を見つけたなら、ローカル開発の段階のうちに修正する絶好の機会です。
9. 図解: ChatGPT がテンプレートとどのようにやり取りするか
全体像を固めるために、シンプルなフローを見てみましょう。
flowchart TD
U[ChatGPT のユーザー] -->|プロンプトを送る| M[ChatGPT モデル]
M -->|tool を呼び出す| MCP["あなたの MCP endpoint
app/mcp/route.ts"]
MCP -->|"MCP の JSON 応答 (structuredContent, _meta, UI リンク)"| M
M -->|UI を表示すると判断| WIDGET_URL["ウィジェットの URL
(/widget または /)"]
WIDGET_URL -->|iframe| W[あなたのウィジェット
app/page.tsx]
W -->|window.openai.toolOutput
+ widgetState を読む| U
ここで重要なのは、ほとんどの場合のイニシエータはユーザーのブラウザではなく ChatGPT のモデルだという点です。 app/mcp/route.ts と app/widget/page.tsx は、 同じ Next.js プロジェクトにある 2 つの別々の「入口」であり、一方はロボット(MCP)向け、もう一方は UI 向けです。
このプロジェクトの地図(ウィジェット → MCP レイヤー → コンフィグ)を頭に入れておき、挙げた落とし穴を意識的に避ければ、 この先は App のロジックと UX に集中できるようになります。「すべてを壊しているあのファイル」を探すことに時間を奪われません。
10. テンプレート構造でよくあるミス
誤り 1: ウィジェットとサイトの通常ページを混同する。
テンプレートに app/page.tsx と app/widget/page.tsx の両方があり、 「違う方」のファイルを修正して ChatGPT に変更が反映されない、ということがあります。ウィジェットとは、 MCP ツールの outputTemplate/iframe として使われるページです。別ルートを変更しても、ChatGPT はそれを認識しません。 必ずテンプレートの README を確認し、どの URL がウィジェットとして指定されているかを見てください。
誤り 2: MCP のサーバーファイルでクライアントコード(window、document)を書く。
app/mcp/route.ts と、それがインポートするすべてのコードはサーバーで実行されます。そこで window や DOM API を使おうとするとランタイムが落ちます。UI で何かしたい場合は、 ほぼ間違いなく app/widget 以下や他のクライアントコンポーネントに書くべきです。 MCP レイヤーは純粋なバックエンド(リクエスト、DB、外部 API、構造化レスポンスの生成)です。
誤り 3: assetPrefix と CORS 設定を無視する。
ローカルの localhost:3000 では完璧に動くのに、ChatGPT のトンネル経由で App を開くとスタイルが消え、 JS が読み込めず、コンソールが CORS エラーだらけ……。原因はたいてい next.config.ts の設定や middleware.ts/proxy.ts の変更にあります。新しい公開 URL を考慮していない、 あるいはリファクタリングで壊れていることが多いです。これらのファイルを変更するときは、 あなたのコードが ChatGPT ドメイン上の iframe 内で動くことを常に念頭に置き、 直接 localhost で動くのとは条件が違うことを意識してください。
誤り 4: シークレットを .env ではなく、コードや NEXT_PUBLIC_* 変数に置く。
app/widget/page.tsx のどこかで const apiKey = 'sk-...' のように OPENAI_API_KEY を隠すのは最悪です。JS バンドルに含まれ、誰のブラウザにも渡ってしまいます。 ほぼ同じくらい悪いのが、NEXT_PUBLIC_OPENAI_API_KEY を作ることです。 NEXT_PUBLIC_ プレフィックスはブラウザへの公開を保証するからです。必ず .env(このプレフィックスなし)に置き、サーバー側(MCP サーバー、バックエンド関数)だけで使いましょう。
誤り 5: テンプレートを「賢すぎる」と思い込んで触るのを恐れる。
公式スターターを神聖視して「下手に触ると統合が壊れるかも」と敬遠し、結果として別の場所にコードを書き散らし、アーキテクチャを複雑にして、 それでも同じ落とし穴を踏む……というケースがあります。実際、テンプレートは Apps SDK 向けの設定が少し入っただけの丁寧に構成された Next.js プロジェクトです。 app/ が UI と MCP を担い、それ以外は通常のコンフィグだと理解できれば肩の力が抜けます。 つまり、魔法の箱ではなく、いつもの React/Next プロジェクトとして扱えばよいのです。
誤り 6: すべての問題を「ウィジェット側」で解決しようとする。
UI で全部やりたくなる(ビジネスロジック、DB アクセス、外部 API 要求)ことがありますが、ChatGPT Apps の文脈では特に悪手です。 ウィジェットは非常に厳しいサンドボックス内で動き、シークレットは見えず、window.openai に強く依存します。 本格的な処理は MCP レイヤーやバックエンドサービスの領域であり、ウィジェットは構造化データを表示し、必要に応じてツールをトリガーする薄いプレゼンテーション層であるべきです。
GO TO FULL VERSION