1. 今日は何を作り、アプリにどう組み込むか
これまでの学習用アプリを思い出しましょう。私たちはギフト選びのアシスタントを作っています。前のモジュールではすでに次のものがありました:
- ChatGPT 内のウィジェット(Next.js 16 + Apps SDK)。UI と状態を表示し、callTool を呼べる;
- シンプルなバックエンド(Apps SDK / Next.js のルート経由)で、ギフトのスタブを返していた。
今回はアシスタントの「頭脳」を別の MCP サーバーに切り出します。最終的なイメージは次のとおりです:
flowchart TD
subgraph ChatGPT
U[ユーザー
(チャット内)]
W["アプリのウィジェット
(Apps SDK)"]
end
subgraph MCP クライアント
C[ChatGPT MCP client]
end
subgraph OurServer[私たちの MCP サーバー]
T1[Tool: suggest_gifts]
R1[Resource: gift_catalog]
P1[Prompt: birthday_template]
end
U --> W
W -- callTool --> C
C <-- JSON-RPC / HTTP --> OurServer
OurServer --> C
C --> W
つまり、これからは:
- ChatGPT 内のモデルが、私たちの MCP サーバーを標準的な tools/resources/prompts の集合として認識する;
- ウィジェットからの callTool は、論理的には内部の MCP 呼び出しになる;
- 私たちのサーバーが契約(スキーマ、説明)を定義し、ビジネスロジックを実装する。
この講義の終わりまでに、MCP サーバーを備えた独立した Node/TypeScript プロジェクトを用意できるはずです。サーバーは次のことを行います:
- 1 コマンドでローカル起動できる;
- 少なくとも 1 つのツールと 1 つのリソースを登録する;
- 意味のあるデータを返す(簡単なモックでも可);
- 今後拡張できるように構造化されている。
なお、既存の Apps SDK/Next.js のバックエンドは今は書き換えません。そのままにしておき、MCP サーバーは別サービスとして横に立てます。後で ChatGPT App に「接続」し、古いスタブの代わりにギフトのロジックを段階的に移行できます。
2. スタック: TypeScript + MCP SDK + HTTP トランスポート
MCP サーバーは Node.js 上の TypeScript で書きます。MCP 向け公式の JS/TS SDK は @modelcontextprotocol/sdk パッケージです。これは JSON‑RPC、検証、スキーマ変換の手間を引き受けます。引数を Zod スキーマで記述すると、SDK がそれをモデルに理解できる JSON Schema に変換してくれます。
トランスポートはHTTP 版が必要です。ChatGPT はリモートの MCP サーバーとネットワーク越しに通信し、stdio/ローカルではありません。MCP の仕様は「ストリーミング HTTP」という標準形式を説明しており、実質的には古い HTTP+SSE の進化版です。実務的には 1 つの HTTP エンドポイントがリクエスト(POST/GET)を受け取り、必要なら応答をストリームします。TypeScript 用 SDK には、Express や Hono に接続できるこの形式のトランスポートが用意されているのが一般的です。
話を広げすぎないため、以下があるものとします:
- サーバーオブジェクト McpServer(@modelcontextprotocol/sdk より);
- HTTP トランスポート(例: StreamableHttpServerTransport など)。Express と組み合わせられるもの。
クラス名は SDK のバージョンで多少異なることがありますが、アーキテクチャは常にこうです:
- MCP サーバーオブジェクトを作成;
- その上に tools/resources/prompts を登録;
- HTTP アプリにトランスポートを接続。
3. プロジェクト構成と準備
MCP サーバー用に別フォルダを作ります。フロントエンドアプリの隣に置くと便利ですが、別の Node プロジェクトにします:
chatgpt-gift-app/
app/ ← Next.js + Apps SDK(ウィジェット)
mcp-server/ ← 私たちの MCP サーバー
mcp-server の中身:
mcp-server/
src/
server.ts ← MCP サーバーのエントリポイント
gifts.ts ← ギフト提案のビジネスロジック
package.json
tsconfig.json
簡単な gifts.ts の例は後で示します。今は server.ts に集中しましょう。
すでにプロジェクトを初期化したとします:
mkdir mcp-server
cd mcp-server
npm init -y
npm install typescript ts-node-dev zod express @modelcontextprotocol/sdk
tsconfig.json はごく普通の設定(esnext modules、target node、strict)で構いません。手元の TS プロジェクトから流用できます。
4. ビジネスロジックを別モジュールに切り出す
すぐに server.registerTool(..., async () => {...}) と書いて、その中に全部のロジックを詰め込みたくなるところですが、最初から分離したほうがよいです:
- MCP や JSON‑RPC などを何も知らないモジュール;
- MCP のことだけを知るモジュール(ビジネスロジックはほとんど知らない)。
src/gifts.ts に、簡単なギフト提案関数を記述します:
// src/gifts.ts
export type GiftIdea = {
id: string;
title: string;
price: number;
occasion: string;
};
export type SuggestGiftsInput = {
age: number;
relationship: "friend" | "partner" | "child" | "coworker";
budget: number;
};
export function suggestGifts(input: SuggestGiftsInput): GiftIdea[] {
// とりあえずモック
return [
{
id: "book-1",
title: "好きな趣味の本",
price: Math.min(input.budget, 30),
occasion: "generic",
},
{
id: "game-1",
title: "みんなで遊べるボードゲーム",
price: Math.min(input.budget, 50),
occasion: "party",
},
];
}
この関数は純粋です。入力はパラメータ、出力はアイデアの配列。ユニットテストで検証でき、他所でも再利用でき、MCP には一切依存しません。サーバーのラッパーとビジネス関数を分けるのが推奨されるやり方です。
5. MCP サーバーを作り、HTTP トランスポートを接続する
次はエントリポイント src/server.ts です。大まかには次が必要です:
- MCP サーバーのインスタンスを作成;
- ツール、リソース、プロンプトを登録;
- HTTP サーバー(例: Express)を立ち上げ、MCP トランスポートを取り付ける。
まずはひな形から:
// src/server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server";
import { StreamableHttpServerTransport } from "@modelcontextprotocol/sdk/transport/streamable-http";
const app = express();
// 1. MCP サーバーを作成
const mcpServer = new McpServer({
name: "gift-assistant-mcp",
version: "0.1.0",
});
// 2. ここで後ほど tools/resources/prompts を登録
// 3. HTTP 上にトランスポートを設定
const transport = new StreamableHttpServerTransport({
path: "/mcp", // MCP の単一エンドポイント
app, // Express アプリに組み込む
});
transport.attach(mcpServer);
const PORT = process.env.PORT ?? 4000;
app.listen(PORT, () => {
console.log(`MCP server listening on http://localhost:${PORT}/mcp`);
});
トランスポートのクラス名は異なる場合がありますが、パターンは同じです。HTTP エンドポイントを作り、MCP サーバーを HTTP/ストリーム上の JSON‑RPC ハンドラとして接続します。
この段階ではサーバーはまだ役に立つことはしませんが、次ができます:
- MCP のハンドシェイクを通過;
- 基本的なディスカバリリクエストに応答(tools/resources/prompts の一覧 — まだ空)。
次のステップは最初のツールを登録することです。
6. MCP SDK で tool suggest_gifts を登録する
公式の Apps SDK と MCP ドキュメントは、ツール登録の同じパターンを示しています。つまり registerTool メソッドに、名前、記述(タイトル、説明、引数スキーマ)、ハンドラを渡します。
すでに gifts.ts に SuggestGiftsInput 型を定義しました。ここに Zod スキーマを加え、サーバーが入力引数を検証し、LLM に正しい JSON Schema を自動提供できるようにしましょう。
// src/server.ts (フラグメント)
import { z } from "zod";
import { suggestGifts } from "./gifts";
const suggestGiftsInputSchema = z.object({
age: z.number().int().min(0).max(120),
relationship: z.enum(["friend", "partner", "child", "coworker"]),
budget: z.number().min(0),
});
ではツールを登録します:
// まだ server.ts の中
mcpServer.registerTool(
"suggest_gifts",
{
title: "Suggest gift ideas",
description:
"年齢、関係性、予算に基づいてギフトのアイデアを提案します。",
// SDK は Zod スキーマをモデル向けの JSON Schema に変換します
inputSchema: suggestGiftsInputSchema,
},
async ({ input }) => {
const ideas = suggestGifts(input);
const text = ideas
.map(
(g) =>
`• ${g.title} — ~${g.price} USD (occasion: ${g.occasion}, id: ${g.id})`
)
.join("\n");
return {
content: [
{
type: "text",
text,
},
],
// structuredContent はウィジェットで利用できます
structuredContent: {
ideas,
},
};
}
);
重要ポイント:
- inputSchema は Zod スキーマです。TS 向け SDK はこれを JSON Schema に変換し、モデルに対してツールを自動的に記述します。
- ハンドラは input を含むオブジェクトを受け取ります(型はスキーマから得られます)。中で自分のビジネス関数を呼べます。
- result では content を返します。これはモデルが結果として見るテキストです。必要に応じて、ウィジェットが後で消費できる JSON 構造の structuredContent も返せます。
前のモジュールで Apps SDK によるツールを作ったことがあるなら、このコードはとても馴染みがあるはずです。パターンはまったく同じで、今回はそれが独立した MCP サーバー内にあるだけです。
7. データ用のリソース gift_catalog を追加する
ツールは「アクション」です。ときにはモデルが読み取ったり検索できるように、あるいはウィジェットがテンプレートやコンポーネントを読み込めるように、データをリソースとして提供したいこともあります。MCP では、URI、MIME タイプ、コンテンツを持つリソースの概念が別途定義されています。
簡単なリソース gift_catalog を作り、利用可能なギフトの一覧を返すようにしましょう。今は同じモックですが、実際には DB からの出力や product feed でもよいでしょう。
まずカタログ本体:
// src/gifts.ts (追記)
export const giftCatalog: GiftIdea[] = [
{
id: "book-1",
title: "プログラミングの本",
price: 25,
occasion: "learning",
},
{
id: "lego-1",
title: "LEGO セット",
price: 60,
occasion: "fun",
},
];
次にサーバー側でリソースを登録します:
// src/server.ts (フラグメント)
import { giftCatalog } from "./gifts";
mcpServer.registerResource(
"gift_catalog",
{
title: "Gift catalog",
description: "デモとデバッグ用の簡単なギフトカタログ。",
mimeType: "application/json",
},
async () => {
return {
contents: [
{
uri: "mcp://gift-catalog",
mimeType: "application/json",
text: JSON.stringify(giftCatalog, null, 2),
},
],
};
}
);
ここで論理的に起こっていること:
- リソース名 gift_catalog はディスカバリ時にクライアントから見えます(MCP インスペクタでリソース一覧に表示されます);
- ディスクリプタには人間が読める説明と MIME タイプが含まれます;
- ハンドラは URI とテキストを持つ contents の配列を返します。これは MCP におけるリソースの標準形式です。
今後は次のことができます:
- クライアント(例: エージェントやインスペクタ)からこのリソースを読む;
- UI のテンプレート/データとして利用する;
- モデルが既存のカタログをどう活用してユーザーに選択肢を説明するか、といった実験を行う。
8. シンプルなプロンプトを登録する
MCP の 3 つ目の要素はプロンプトです。あらかじめ用意した指示の塊をサーバー側に名前付きで保持しておくことで、長いシステム/ユーザープロンプトを繰り返さずに済みます。
ミニ例として、誕生日プレゼントの会話を「事前入力」できるプロンプト birthday_gift を作りましょう。
// src/server.ts (フラグメント)
mcpServer.registerPrompt("birthday_gift", {
title: "Birthday gift helper",
description: "誕生日プレゼント選びのためのリクエストテンプレート。",
messages: [
{
role: "system",
content:
"あなたはギフト探しのアシスタントです。確認質問を行い、いくつかの候補を提案してください。",
},
{
role: "user",
content:
"誕生日プレゼントが必要です。必要な確認事項を質問し、選ぶのを手伝ってください。",
},
],
});
内部的には MCP によりクライアントは次のことができます:
- プロンプトの一覧を取得(インスペクタで birthday_gift が見える);
- その内容を取得し、モデルへの基本的な指示として利用。
システムプロンプトと指示のモジュールでは、こうしたプロンプトがアプリのグローバルな指示とどのように組み合わさるかを詳しく扱います。ここでは単に、それらが MCP サーバーの一部として「見える」ことが重要です。
9. ランタイムでの挙動
全体像をまとめましょう。
クライアント(例: MCP Inspector や ChatGPT)が私たちの HTTP エンドポイント /mcp に接続すると:
- ハンドシェイクが行われ、クライアントとサーバーは対応機能(tools/resources/prompts など)の情報を交換します;
- クライアントがディスカバリメソッドを呼び、ツール、リソース、プロンプトの一覧と、それぞれの説明やスキーマを取得します;
- モデルがツールを呼ぶとき、tools/call のようなメソッドを持つ JSON‑RPC リクエストを形成します。サーバー側 SDK はそれを内部の registerTool ハンドラ呼び出しに変換します;
- ハンドラがビジネスロジック(ここでは suggestGifts や giftCatalog の返却)を実行し、標準化された形式で結果を返します;
- SDK は応答を JSON‑RPC にシリアライズし、同じ HTTP/ストリームトランスポートでクライアントへ返送します。
JSON‑RPC の詳細、id の生成、メソッドのルーティングなどはすべて @modelcontextprotocol/sdk 内に隠蔽されています。あなたにとってのインターフェースは Apps SDK と非常によく似ています。つまり registerTool/registerResource/registerPrompt とハンドラに向き合えばよく、プロトコルを意識する必要はあまりありません。
10. ローカル起動と最初の簡単なテスト
ここまでの内容を追加したと仮定します。あとは起動です。
package.json にスクリプトを追加します:
{
"scripts": {
"dev": "ts-node-dev src/server.ts"
}
}
実行:
npm run dev
コンソールには次のような表示が出るはずです:
MCP server listening on http://localhost:4000/mcp
本格的なインスペクションや手動でのツール呼び出しは、次の講義で MCP Inspector / MCP Jam を使って行います。でも今でも、curl による超簡単なスモークテストは可能です:
curl -X POST http://localhost:4000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
この curl は、「生」の JSON 応答を見るのが好きな人向けの任意のスモークテストです。実際の開発では、手で JSON‑RPC リクエストを組み立てるのではなく、ほぼ常に SDK 経由で MCP サーバーとやり取りします。
メソッド名はプロトコルと SDK のバージョンに依存しますが、狙いは tools の中に suggest_gifts が見えるような JSON の一覧を得ることです。名前が一致しなくても問題ありません。重要なのは、JSON の応答を見ることを恐れないこと、そして前の講義のおかげでその構造を理解できることです。
11. ChatGPT App との連携と今後の発展
今のところ、MCP サーバーは単体で動いています。次のモジュールでは次のことを行います:
- MCP Inspector に接続し、ChatGPT を触らずに tools/resources/prompts を個別にデバッグする;
- ChatGPT App がこの MCP サーバーをツールの供給源として認識するよう設定する;
- 以前は Apps SDK 内(例えば組み込みツール)で実装していたロジックの一部を、MCP レイヤーに移す;
- 認可、ロギング、ストリーミングシナリオなどを、完成した骨組みの上に追加する。
今重要なのは次の点です:
- アプリの「能力」と「データ」を担う独立したサービスがある;
- このサービスは標準の MCP でクライアントと話し、カスタム REST ではない;
- プロトコルを恐れずに、ツール、リソース、プロンプトを手動で登録できるようになった。
12. コード構成とベストプラクティスについて少し
この小さな例でも、よい習慣を埋め込むことができます。
まず、サーバーの設定を分離しましょう。名前、バージョン、ロギング、トランスポート設定(ポート、パス /mcp)などは小さな config.ts に切り出すと簡単です。Vercel へデプロイしたり MCP ゲートウェイの背後に置くときに環境変数を足すことになり、後で自分を褒めるはずです。
次に、registerTool/registerResource/registerPrompt のメソッドはできるだけ「薄く」保ちましょう。スキーマ、テキスト、ビジネスロジックの記述は別ファイルにあると見通しがよくなります:
- gifts.ts — ギフト選定の関数;
- catalog.ts — 商品カタログの処理;
- prompts.ts — プロンプトのセット。
こうすると server.ts 自体は、すべてをつなぐ「MCP プロバイダー」のような役割に収まります。
三つ目として、MCP サーバーは本質的にリアクティブで、クライアントの接続とリクエストを待ち受けます。つまり、ツール内のブロッキングや過度に長い処理は、ChatGPT の UX に直結します。次のモジュールでタイムアウト、非同期処理、ストリーミング応答について詳しく扱いますが、今のうちから、どの処理をバックグラウンドへ回し、どれを高速応答にすべきかを考えておきましょう。
インサイト: ChatGPT は MCP の一部のみをサポート
重要な点として、ChatGPT Apps は MCP をトランスポートとフォーマットとして使いますが、完全な MCP クライアントではありません。プロトコルだけを読んでしまうと、ランタイムでの挙動に誤った期待を抱きがちです。
「純粋な」MCP が約束すること:
- リソース(resources)はクライアントの要求に応じて動的に読める(1 回限りではない);
- サーバーは resourceChanged/toolChanged 通知を送って、クライアントの再起動なしに更新を「プッシュ」できる;
- tools/resources/prompts のセットを設定ファイルや外部状態で柔軟に管理できる。
ChatGPT Apps の文脈ではそうではありません。アプリにとっては、かなり静的な絵になります:
- App の登録時に、ChatGPT はすべての tools と resources の記述を 1 度だけ読み込み;
- その後、この設定は事実上、アプリのバージョンの一部としてキャッシュされます;
- MCP 通知による動的更新はサポートされません。プラットフォームはそれらを無視します。
13. 初めての MCP サーバーでよくある間違い
間違い №1: ビジネスロジックを registerTool の中に全部詰め込む。
学習用の例では特に、「とりあえずハンドラ内にすべてを書いてしまう」という誘惑が強いです。しかしその後、検証、DB 処理、レスポンス整形がごちゃ混ぜの読みにくいコンバインに化けます。ビジネス関数(suggestGifts、カタログ処理など)は最初から別モジュールに切り出し、ハンドラでは「つなぎ込み」だけにしましょう。
間違い №2: MCP の JSON メソッド名にハードコードで依存する。
学生がたまに、if (method === "tools/list") のように条件分岐を書き、手で JSON をパースしようとします。そうする必要はありません。それは SDK の仕事です。MCP の仕様やメソッド名は進化しうるので、そこは SDK に任せましょう。registerTool、registerResource、registerPrompt を使い、JSON‑RPC の形はライブラリに委ねてください。
間違い №3: トランスポートを考えず、ChatGPT に stdio サーバーを食べさせようとする。
stdio トランスポートは、クライアントがサーバーをサブプロセスとして起動できるデスクトップ環境のようなローカルクライアントに最適です。しかし ChatGPT は HTTPS で通信し、HTTP/ストリームのエンドポイントが必要です。「stdio をどうにかトンネル経由で…」は痛みの元です。ChatGPT App 向けには最初から HTTP トランスポート(Streamable HTTP)にしましょう。
間違い №4: MIME タイプやリソース構造を無視する。
リソースに重要なのはコンテンツだけではなく、型(mimeType)や URI です。どこでも text/plain と書いて無造作に JSON 文字列を投げると、クライアント(やインスペクタ)はデータの意味を把握しづらくなります。適切な MIME タイプ(application/json、UI テンプレートなら text/html など)と安定した URI を心がけましょう。
間違い №5: MCP サーバーを「ランダムな HTTP API」として使う。
「せっかく Express があるから /api/whatever もぶら下げて直接叩こう」という誘惑が生まれます。MCP エンドポイントに任意の REST を混ぜるのは避けたほうがよいです。設定、ルーティング、セキュリティが複雑になります。/mcp は MCP 用、他の用途は別パス、もしくは別サービス、という明確な契約にするのが簡単です。プロダクションではゲートウェイや認可の構成上、特に重要です。つまり、MCP サーバーを「ランダムな HTTP API」(MCP 契約と無関係な HTTP の寄せ集め)にしないこと。
間違い №6: 入出力の MCP メッセージをログに残さない。
ログがない MCP サーバーはブラックボックスです。「何か動かないが、何が起きているかわからない」。最初のサーバーでも、少なくとも stderr にコンパクトな構造化ログ(ツールメソッド、ステータス、処理時間)を書くのがよいでしょう。機微情報やトークンはログに出さないことは最重要で、セキュリティの節で改めて扱います。
間違い №7: インスペクタなしに、いきなり ChatGPT で全部をデバッグしようとする。
ありがちな光景: 受講生が MCP サーバーを書いてすぐに ChatGPT App に接続し、「よくわからないまま壊れる」。その一方でインスペクタは一度も起動していない。結果、問題がプロトコルなのか、サーバーなのか、Apps SDK なのか、モデルの振る舞いなのかがわかりません。正しい手順は、まず MCP サーバーが単体で正しく動作する(MCP Jam / Inspector 経由)ことを確認し、その後アプリに接続することです。
GO TO FULL VERSION