
MCP Apps で「AIと対話しながら買い物できるECショップ」を作ってみた
こんにちは、マッハチームの日吉です。
MCP Appsを使って、ChatGPTやClaudeのチャット画面内にインタラクティブなECショップUIを埋め込み、商品検索 → カート追加 → 購入 → 購入履歴に基づくコーディネート提案まで、会話の中でシームレスに完結する体験を作りました。
はじめに
「春物の洋服が欲しいな」とAIに話しかけると、チャット画面に商品カードがずらっと並ぶ。気に入った商品をポチッとカートに入れて、そのまま購入。後日「前に買った服に合うコーディネートを提案して」と聞くと、購入履歴のタグ情報をもとにAIが提案してくれる ── そんな体験サンプルを MCP Apps で実装してみました。
MCP Appsの概要については以下のブログをご覧ください。
本記事では、以下を一通り「やってみた」記録としてまとめます。
- MCP Apps の基本アーキテクチャ
- React + Vite で View(UI)を実装する方法
- Claude / ChatGPT で動かすまでの手順
- 購入履歴 × LLM でコーディネート提案を実現する仕組み
完成イメージ
想定される会話フロー:
ユーザー:春物の洋服が欲しいな
AI:春物の商品を検索しますね。
→ [商品一覧View: 春タグの商品がカードグリッドで表示]
ユーザー:このジャケットとシャツをカートに入れて
AI:カートに追加しました。
→ [カートDrawer: 商品・合計金額・購入ボタン表示]
ユーザー:購入する
AI:注文が確定しました。
→ [注文確認View: 注文ID・明細表示]
ユーザー:そういえば、前に買った洋服ってどれだっけ?
AI:購入履歴をお見せしますね。
→ [購入履歴View: 過去に買った商品がカードグリッドで表示]
ユーザー:前に買った服にマッチする春物を提案して
AI:購入履歴を見ると、きれいめ・通勤系がお好みのようですね。
こちらはいかがでしょうか。
→ [商品一覧View: きれいめ×春の商品が表示]
使用した技術スタック
| 技術 | 用途 |
|---|---|
@modelcontextprotocol/sdk |
MCP サーバー実装(WebStandardStreamableHTTPServerTransport でHTTP公開) |
@modelcontextprotocol/ext-apps |
MCP Apps のView実装(React hooks + サーバー側登録) |
| React 19 + TypeScript | View(UI)コンポーネント |
| Vite + vite-plugin-singlefile | View を単一HTMLファイルにバンドル |
| Next.js 15 (App Router) | Vercel上でMCPサーバーをHTTPエンドポイントとして公開 |
| Vercel | デプロイ先 |
MCP Apps のアーキテクチャ
通常のMCPサーバーとの違いを図で示します。
【従来のMCPサーバー】
ユーザー → LLM → MCP Server → テキスト結果 → LLM → テキスト応答
【MCP Apps】
ユーザー → LLM → MCP Server → テキスト結果 + resourceUri
↓
ホスト(ChatGPT等)が resourceUri のHTMLを取得
↓
iframe内にリッチUIを表示
↓
View内から app.callServerTool() で他ツール呼出
ポイントは3つです。
- ツール定義に
resourceUriを紐づける:registerAppTool()の_meta.ui.resourceUriで、そのツールの結果をどのViewで表示するかを指定 - View は単一HTMLファイル: ホストがiframe内にロードするため、CSS/JSをすべてインライン化した自己完結型HTML
- 双方向通信: View内のReactアプリから
app.callServerTool()で別のMCPツールを呼び出せる(例: 商品一覧Viewの「カートに追加」ボタン →add_to_cartツール呼出)
Step 1: プロジェクト構成
mcp-apps-sample/
├── app/api/mcp/route.ts # Next.js API Route(MCPエンドポイント)
├── src/
│ ├── tools/
│ │ ├── products.ts # 商品検索・一覧・詳細ツール
│ │ ├── cart.ts # カート操作ツール
│ │ └── orders.ts # 注文・購入履歴ツール
│ ├── resources.ts # MCP Resources(View HTML配信)
│ ├── data/mock.ts # モックデータ(商品 + 注文履歴)
│ └── server.ts # MCP Server 初期化
├── views/ # React View ソースコード
│ ├── shared/styles.css # デザインシステム
│ ├── product-catalog/App.tsx # 商品一覧View
│ ├── cart/App.tsx # カートView
│ ├── order/App.tsx # 注文確認View
│ └── purchase-history/App.tsx # 購入履歴View
├── scripts/
│ └── embed-views.mjs # ビルド済HTMLをTSファイルに埋め込み
├── vite.config.ts # Vite設定(singlefile出力)
└── package.json
ビルドパイプライン:
views/*.tsx (React)
↓ Vite + vite-plugin-singlefile
dist/views/*.html (単一HTMLファイル)
↓ scripts/embed-views.mjs
src/view-html.ts (HTMLを文字列としてexport)
↓ registerAppResource()
MCP Resource として配信
Step 2: ツール定義(registerAppTool)
従来の server.tool() の代わりに、@modelcontextprotocol/ext-apps/server の registerAppTool() を使います。
// src/tools/products.ts
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
import { RESOURCE_URIS } from "../resources.js";
export function registerProductTools(server: McpServer) {
registerAppTool(
server,
"search_products",
{
title: "商品検索",
description: "キーワード、カテゴリ、タグで商品を検索します。",
inputSchema: {
keyword: z.string().optional().describe("検索キーワード"),
category: z.string().optional().describe("カテゴリ名で絞り込み"),
tags: z.array(z.string()).optional()
.describe("タグで絞り込み(AND条件)。例: [\"春\", \"アウター\"]"),
},
_meta: {
ui: { resourceUri: RESOURCE_URIS.productCatalog }, // ← このViewで結果を表示
},
},
async ({ keyword, category, tags }) => {
// 検索ロジック(省略)
const data = { count: results.length, products: summary };
return {
structuredContent: data, // ← ChatGPT向け(後述)
content: [{
type: "text",
text: JSON.stringify(data, null, 2),
}],
};
},
);
}
ポイント:
_meta.ui.resourceUri: ツールの結果をどのViewで表示するかを指定。CSP/domain設定はツールではなくリソース側に記述します(後述の「ChatGPT対応」で詳しく解説)。structuredContent: ChatGPTのApps対応で必要になるフィールド。contentと同じデータを生のオブジェクトとして渡します(後述)。
今回作成したツール一覧:
| ツール名 | 説明 | 紐づくView |
|---|---|---|
search_products |
商品検索(キーワード/カテゴリ/タグ) | 商品一覧 |
list_categories |
カテゴリ一覧 | 商品一覧 |
get_product |
商品詳細 | 商品一覧 |
add_to_cart |
カートに追加 | カート |
remove_from_cart |
カートから削除 | カート |
get_cart |
カート表示 | カート |
checkout |
注文確定 | 注文確認 |
get_purchase_history |
購入履歴(タグ付き) | 購入履歴 |
list_orders |
注文履歴一覧 | 注文確認 |
get_order |
注文詳細 | 注文確認 |
Step 3: View の実装(React + ext-apps SDK)
各ViewはReactアプリで、useApp フックでMCPホストと接続します。
// views/product-catalog/App.tsx(抜粋)
import { useState, useEffect } from "react";
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
function App() {
const [products, setProducts] = useState<Product[]>([]);
// ツール結果をパースする共通ハンドラ
const handleToolResult = (params: any) => {
// 1. structuredContent(ChatGPT経由)を優先チェック
const sc = params?.structuredContent;
if (sc && typeof sc === "object" && sc.products) {
setProducts(sc.products);
return;
}
// 2. content テキスト(標準MCP)をフォールバック
const text = params?.content?.find(
(c: { type: string }) => c.type === "text",
) as { text: string } | undefined;
if (text) {
try {
const data = JSON.parse(text.text);
if (data.products) setProducts(data.products);
} catch { /* ignore */ }
}
};
const { app, isConnected } = useApp({
appInfo: { name: "ec-shop", version: "1.0.0" },
capabilities: {},
onAppCreated: (app) => {
app.ontoolresult = handleToolResult;
},
});
// ホストのテーマ(ダークモード等)を自動適用
useHostStyles(app, app?.getHostContext());
// フォールバック: 直接 postMessage をリスン(ext-apps のスキーマ検証をバイパス)
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.source !== window.parent) return;
const msg = event.data;
if (!msg || msg.jsonrpc !== "2.0") return;
if (msg.method !== "ui/notifications/tool-result") return;
handleToolResult(msg.params);
};
window.addEventListener("message", handler, { passive: true });
return () => window.removeEventListener("message", handler);
}, []);
// フォールバック: window.openai(ChatGPT固有API)
useEffect(() => {
const openai = (window as any).openai;
if (openai?.toolOutput) handleToolResult(openai.toolOutput);
}, []);
// View内から別のツールを呼び出す
const addToCart = async (productId: string) => {
const result = await app.callServerTool({
name: "add_to_cart",
arguments: { productId, quantity: 1 },
});
// カート更新のUIフィードバック
};
return (
<div>
<div className="grid">
{products.map(p => (
<ProductCard key={p.id} product={p} onAddToCart={addToCart} />
))}
</div>
</div>
);
}
双方向通信の流れ:
- LLMが
search_productsを呼ぶ → ツール結果がViewに届く(3つの経路のいずれかで受信) - ユーザーがView内の「+ Cart」ボタンをクリック →
app.callServerTool()でadd_to_cartを呼ぶ - さらに「Checkout」ボタン →
checkoutツールを呼んで注文確定
これにより、チャット画面を離れることなく一連の購入体験が完結します。
Step 4: View のビルドとリソース登録
Vite設定(vite.config.ts):
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
root: path.resolve(__dirname, "views"),
plugins: [react(), viteSingleFile()],
build: {
outDir: path.resolve(__dirname, "dist/views"),
rollupOptions: {
input: path.resolve(__dirname, `views/${process.env.VIEW}/index.html`),
},
},
});
vite-plugin-singlefile がCSS・JSをすべてHTMLにインライン化し、外部依存なしの単一ファイルを生成します。
リソース登録(src/resources.ts):
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import { VIEW_HTML } from "./view-html.js";
const views = [
{ name: "商品一覧", uri: "ui://ec-shop/product-catalog.html", key: "productCatalog" },
{ name: "カート", uri: "ui://ec-shop/cart.html", key: "cart" },
{ name: "注文確認", uri: "ui://ec-shop/order.html", key: "order" },
{ name: "購入履歴", uri: "ui://ec-shop/purchase-history.html", key: "purchaseHistory" },
];
export function registerResources(server: McpServer) {
const baseUrl = getBaseUrl(); // e.g. "https://mcp-apps-sample.vercel.app"
const uiMeta = {
prefersBorder: true,
domain: baseUrl,
csp: {
connectDomains: [baseUrl],
resourceDomains: [baseUrl],
},
};
for (const view of views) {
registerAppResource(server, view.name, view.uri,
{ mimeType: RESOURCE_MIME_TYPE, _meta: { ui: uiMeta } },
async () => ({
contents: [{ uri: view.uri, mimeType: RESOURCE_MIME_TYPE, text: VIEW_HTML[view.key], _meta: { ui: uiMeta } }],
}),
);
}
}
重要: CSP(connectDomains / resourceDomains)と domain はリソース側の _meta.ui に設定します。ツール側の _meta.ui には resourceUri のみを記述してください。この区別はChatGPTで正しく動作させるために必須です(詳細は後述の「ChatGPT対応」セクション参照)。
Step 5: 購入履歴 × LLM コーディネート提案
今回の目玉機能です。モック購入履歴を事前に用意し、LLMがユーザーの好みを分析して提案できるようにしました。
モック購入履歴(src/data/mock.ts):
export const orders: Order[] = [
{
id: "ORD-000001",
items: [
{ productId: "fashion-003", productName: "テーラードジャケット(スプリング)", quantity: 1, unitPrice: 14800 },
{ productId: "fashion-001", productName: "リネンブレンドシャツ", quantity: 2, unitPrice: 5980 },
],
totalAmount: 26760,
status: "delivered",
createdAt: "2025-10-15T10:30:00Z",
},
{
id: "ORD-000002",
items: [
{ productId: "fashion-021", productName: "カシミヤブレンドコート", quantity: 1, unitPrice: 34800 },
{ productId: "fashion-024", productName: "ウールマフラー(カシミヤ混)", quantity: 1, unitPrice: 8980 },
],
totalAmount: 43780,
status: "delivered",
createdAt: "2025-12-01T14:00:00Z",
},
{
id: "ORD-000003",
items: [
{ productId: "fashion-028", productName: "レザースニーカー", quantity: 1, unitPrice: 12800 },
{ productId: "fashion-027", productName: "スリムフィットデニムパンツ", quantity: 1, unitPrice: 6980 },
],
totalAmount: 19780,
status: "shipped",
createdAt: "2026-02-20T09:00:00Z",
},
];
get_purchase_history ツール:
既存の list_orders(注文サマリーのみ)とは別に、商品のタグ・カテゴリ情報付きで購入商品を返すツールを追加しました。
registerAppTool(server, "get_purchase_history", {
title: "購入履歴",
description: "過去に購入した商品の一覧を取得します。商品名、カテゴリ、タグ、購入日が含まれるため、好みの分析やコーディネート提案に活用できます。",
_meta: { ui: { resourceUri: RESOURCE_URIS.purchaseHistory } },
}, async () => {
const purchasedProducts = orders.flatMap((order) =>
order.items.map((item) => {
const product = products.find((p) => p.id === item.productId);
return {
productId: item.productId,
name: item.productName,
price: `¥${item.unitPrice.toLocaleString()}`,
category: product?.category ?? "不明",
tags: product?.tags ?? [], // ← LLMがここを読む
imageUrl: product?.imageUrl ? `${baseUrl}${product.imageUrl}` : undefined,
purchasedAt: order.createdAt,
orderId: order.id,
};
}),
);
const data = { totalOrders: orders.length, purchasedProducts };
return {
structuredContent: data,
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
});
LLMが提案に使う情報の流れ:
get_purchase_history の結果:
fashion-003: tags = ["春", "アウター", "オフィスカジュアル", "きれいめ", "通勤"]
fashion-001: tags = ["春", "トップス", "オフィスカジュアル", "リネン"]
fashion-021: tags = ["冬", "アウター", "きれいめ", "通勤", "カシミヤ"]
...
→ LLMの分析:「きれいめ・通勤・オフィスカジュアル系が好み」
→ search_products({ tags: ["春", "きれいめ"] }) で提案
ツールの description に「好みの分析やコーディネート提案に活用できます」と明記することで、LLMが適切なタイミングでこのツールを選択してくれます。
Tips
View の下に大きな白い余白ができる
MCP Apps SDK の autoResize 機能は、document.documentElement に一時的に width: fit-content と height: fit-content を設定してサイズを測定します。
問題は、width: fit-content を設定した瞬間に CSS Grid の auto-fill カラム数が変わってリフローが起き、実際のレイアウトよりはるかに大きな高さが報告されることでした。
/* 解決策: SDKが width: fit-content を設定しても幅が縮まないようにする */
html {
min-width: 100vw;
}
ChatGPT の Apps で動かしてみた
MCP Apps はChatGPT上で「Apps in ChatGPT」として動作します。実際にChatGPTで動かしてみると、いくつかのハマりポイントがありました。
サーバー側: WebStandardStreamableHTTPServerTransport を直接使う
当初は mcp-handler パッケージを使っていましたが、MCP SDKの WebStandardStreamableHTTPServerTransport を直接使う方式に切り替えました。
mcp-handler vs 直接SDK: なぜ切り替えたか
| 観点 | mcp-handler |
直接SDK (WebStandardStreamableHTTPServerTransport) |
|---|---|---|
| 抽象度 | 高い(Next.js統合済み) | 低い(自分でCORS/ルーティングを書く) |
| デバッグ | ラッパー内部が見えにくい | レスポンスを直接制御でき、問題の切り分けが容易 |
| SSE対応 | 自動 | enableJsonResponse: true で無効化(サーバーレス向け) |
| セッション | 内部管理 | sessionIdGenerator: undefined で明示的に無効化 |
| ChatGPT互換 | 動作未確認のケースあり | 公式SDKのため互換性が高い |
mcp-handler は「とりあえず動かす」には便利ですが、ChatGPTで発生した問題の原因がラッパーの内部挙動にあるのか、プロトコルレベルの問題なのか切り分けが難しくなりました。直接SDKに切り替えることで、リクエスト/レスポンスを完全に制御でき、問題の特定が容易になりました。
なお、mcp-handler が悪いわけではなく、Claude DesktopやシンプルなMCPサーバーでは十分に機能します。ChatGPTのApps対応で細かい挙動の制御が必要になった場合に、直接SDKが選択肢になるということです。
// app/api/mcp/route.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
async function handleMcpRequest(request: Request): Promise<Response> {
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // ステートレス(Vercel Serverless向け)
enableJsonResponse: true, // SSEではなくJSONレスポンス
});
const server = new McpServer(
{ name: "ec-shop", version: "1.0.0" },
{ capabilities: {} },
);
registerProductTools(server);
registerCartTools(server);
registerOrderTools(server);
registerResources(server);
await server.connect(transport);
const response = await transport.handleRequest(request);
return addCorsHeaders(response);
}
ハマりポイント1: CSP/ドメイン設定はリソースに書く
ChatGPTのApps設定画面でMCPサーバーURLを登録すると、接続チェックが行われます。当初、ツール側の _meta.ui にCSP設定を入れていたところ、以下のエラーが表示されました。
ウィジェット CSP がこのテンプレートに設定されていません
ウィジェットドメインがこのテンプレートに設定されていません
OpenAIのApps SDKドキュメントを確認したところ、ツール側には resourceUri のみ、CSP/domain設定はリソース側に記述する必要がありました。
✗ ツール: _meta.ui = { resourceUri, csp, domain } ← NG
✓ ツール: _meta.ui = { resourceUri } ← OK
✓ リソース: _meta.ui = { domain, csp, prefersBorder } ← CSP/domainはここ
ハマりポイント2: structuredContent がないとViewにデータが届かない
CSP/ドメインエラーを解消した後も、Viewは「Loading...」のまま表示されませんでした。
ChatGPTはツール結果をViewに渡す際、content(テキスト配列)ではなく structuredContent(生のJSONオブジェクト) を優先的に使います。サーバーから structuredContent を返さないと、Viewにデータが届きません。
// ツールの戻り値: content と structuredContent の両方を返す
const data = { count: results.length, products: summary };
return {
structuredContent: data, // ← ChatGPT用
content: [{ type: "text", text: JSON.stringify(data, null, 2) }], // ← Claude等用
};
ハマりポイント3: ext-apps SDK が structuredContent を空オブジェクトにする
さらに厄介だったのが、@modelcontextprotocol/ext-apps のクライアントSDK側の問題です。
ext-apps SDKの通知スキーマでは、structuredContent のZod定義が z.ZodObject<{}, z.core.$strip> となっており、すべてのプロパティがストリップ(除去)されます。つまり、ChatGPTが structuredContent: { products: [...] } を送っても、ontoolresult コールバックには structuredContent: {} として届いてしまいます。
この問題を回避するため、View側で3つのデータ受信経路を用意しました。
1. ontoolresult(ext-apps標準)
→ Claude Desktop等で動作。structuredContentはスキーマ検証で空になる可能性あり。
2. 直接 postMessage リスナー(ext-appsのスキーマ検証をバイパス)
→ window.addEventListener("message") で ui/notifications/tool-result を直接処理。
→ structuredContent がそのまま届く。
3. window.openai.toolOutput(ChatGPT固有API)
→ ChatGPTが提供する専用API。ページロード時にツール結果がセットされている。
// View側の3つのデータ受信経路
// 経路1: ext-apps SDK経由(標準)
const { app } = useApp({
onAppCreated: (app) => {
app.ontoolresult = handleToolResult;
},
});
// 経路2: postMessage直接リスン(ext-appsスキーマ検証バイパス)
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.source !== window.parent) return;
const msg = event.data;
if (msg?.jsonrpc === "2.0" && msg.method === "ui/notifications/tool-result") {
handleToolResult(msg.params);
}
};
window.addEventListener("message", handler, { passive: true });
return () => window.removeEventListener("message", handler);
}, []);
// 経路3: ChatGPT固有API
useEffect(() => {
const openai = (window as any).openai;
if (openai?.toolOutput) handleToolResult(openai.toolOutput);
}, []);
handleToolResult 内では structuredContent を優先的にチェックし、なければ content のテキストをJSONパースするフォールバック処理を行っています。
上記の「3つの受信経路」はおそらくハック的な修正方法で、暫定策であり、将来のSDKアップデートで不要になる、または他に良い方法があるかもしれません。
制約と注意点
本サンプルは MCP Apps のデモであり、そのまま本番利用できるものではありません。実用化に向けて認識しておくべき制約を整理します。
セキュリティ
| 項目 | 現状 | 本番で必要な対応 |
|---|---|---|
| CORS | Access-Control-Allow-Origin: * |
許可するオリジンを明示的に制限。ChatGPTのみなら https://chatgpt.com 等に限定 |
| postMessage origin検証 | event.source === window.parent + JSON-RPC構造検証のみ |
MCP Apps Viewは任意のホストに読み込まれるため、origin固定のallowlistは設定不可。データ形状の検証(型ガード)で防御 |
| 認証/認可 | なし(モックデータのため不要) | 実データを扱う場合、MCP接続時の認証トークン検証、ユーザー単位のデータ分離が必須 |
| 注文API保護 | なし(checkout は誰でも呼べる) |
レート制限、CSRF対策、決済連携時のidempotency key |
| 入力バリデーション | Zodスキーマによる基本検証のみ | SQLインジェクション等は現状モックのため問題ないが、DB接続時には厳格な検証が必要 |
postMessage の origin 検証については、MCP Apps の設計上の制約です。View は任意の MCP ホスト(ChatGPT、Claude Desktop、将来の新しいホスト)の iframe 内に読み込まれるため、送信元のオリジンを事前に固定できません。代わりに、受信データの構造を厳格に検証する方針としています(views/shared/tool-result.ts に集約)。
データモデルと提案機能の限界
購入履歴ベースの提案機能は、現状「タグをLLMに渡して推論を任せる」形です。
- タグ品質: 手動付与のモックデータ。実運用では商品マスタの品質管理が必要
- 重み付けなし: 購入回数、購入時期、閲覧履歴などの重み付けは行っていない
- 季節性: タグに「春」「冬」はあるが、現在時刻との照合は LLM の判断任せ
- 説明可能性: LLMの推論過程は非透明。「なぜこの商品を提案したか」の根拠表示は未実装
- タグ体系: フラットなタグ配列のため、「カジュアル > ストリート」のような階層関係を表現できない
これらは MCP Apps 自体の制約ではなく、データモデル設計の問題です。LLM に渡す context の品質が提案の質を左右するため、本番化する際はこの層への投資が最も重要になります。
まとめ
MCP Apps を使うことで、以下のような体験を実現できました。
- チャット内完結のEC体験: 商品閲覧・カート・購入がすべてチャット画面内で完結
- AIによるパーソナライズ提案: 購入履歴のタグ情報をLLMが読み取り、好みに合った商品を提案
- 双方向通信: View内のボタン操作 → MCPツール呼出 → 結果をリアルタイム反映
- ChatGPT互換:
structuredContentと複数のデータ受信経路により、ChatGPTのApps機能で動作
ChatGPTで実際に動かしてみて感じたのは、仕様としてのMCP Appsと、各ホスト(ChatGPT、Claude等)の実装の間にはまだギャップがあるということです。特に structuredContent の扱いやCSP/domain設定の場所など、ドキュメントだけでは分からないハマりポイントが多く、試行錯誤が必要でした。
一方で、一度動いてしまえば体験は非常に強力です。「AIとUIの融合」という方向性は大きな可能性を感じます。
今後は、実際のデータベース連携や、ユーザー認証を組み合わせた本格的なパーソナライズにも挑戦してみたいと思います。









