この記事の対象読者
- JavaScriptまたはTypeScriptの基本文法を理解している方
- MCPという単語を聞いたことがあるが、まだ触っていない方
- AIツールとの連携に興味があるフロントエンド/バックエンド開発者
- 「AIチャットの次」を知りたいエンジニア
この記事で得られること
- MCP Appsの全体像: なぜ今この技術が注目されているか、背景から理解できる
- 動くコードの入手: コピペで動くMCP Appサーバーを手元で構築できる
- 環境別の設定テンプレート: 開発・本番・テスト用の設定ファイルがそのまま使える
- トラブルシューティング力: よくあるエラーと対処法を事前に把握できる
この記事で扱わないこと
- MCP Appsのクライアント(ホスト)側の実装詳細
- React / Vue / Svelte などフレームワーク別のUI実装パターン
- MCPプロトコル仕様のバイト列レベルの解説
1. MCP Appsとの出会い
2026年1月26日の朝、Xのタイムラインを眺めていたら目に飛び込んできた。
「MCP Apps、リリースされました」
正直、最初は「また新しいバズワードか」と思った。MCPサーバーを触ったことはあったし、Claude DesktopからSlackに投稿するくらいならできていた。でも「AIチャットの中にUIが表示される」と聞いたとき、手が止まった。
テキストで「売上データを見せて」と頼んで、チャット内にインタラクティブなダッシュボードが現れる。フィルタもクリックで操作できる。「東京だけ表示して」とチャットし直さなくていい。
これは面白い。実際にQuickstartを試してみたら30分で動いた。ただ、公式ドキュメントは英語で、しかも前提知識が結構必要だった。「MCPのToolsとResourcesに慣れている人向け」と書いてある。
そこで本記事では、MCPに触れたことがない方でも理解できるよう、ゼロからMCP Appsを解説する。料理で例えるなら、MCPは「AIに渡すレシピのフォーマット」、MCP Appsは「レシピに写真や動画を埋め込めるようにした拡張規格」だ。
ここまでで、MCP Appsがどんなものか、なんとなくイメージできただろうか。次は、この記事で使う用語を整理しておこう。
2. 前提知識の確認
本題に入る前に、この記事で登場する用語を確認する。
2.1 MCP(Model Context Protocol)とは
AIモデルと外部ツールを接続するためのオープンプロトコル。2024年末にAnthropicが発表した。
よく使われる比喩は「AIのためのUSB-Cポート」。USB-Cがどんなデバイスでも統一規格で充電・データ転送できるように、MCPはどんなAIモデルでも統一規格でツールに接続できる。
2.2 MCPサーバーとMCPクライアント
| 用語 | 役割 | 具体例 |
|---|---|---|
| MCPクライアント(ホスト) | AIモデルを搭載し、ユーザーとの対話を管理 | Claude Desktop, VS Code, ChatGPT |
| MCPサーバー | 外部ツール・データへのアクセスを提供 | GitHub MCP Server, PostgreSQL MCP Server |
クライアントがサーバーにリクエストを送り、結果を受け取る。レストランに例えると、クライアントが「お客様(ユーザー)の注文を受けるホールスタッフ」で、MCPサーバーが「調理場のシェフ」だ。
2.3 iframe(インラインフレーム)とは
Webページの中に別のWebページを埋め込むHTML要素。<iframe> タグで実現される。
MCP Appsでは、AIチャットの中にiframeを埋め込んでUIを表示する。セキュリティのため、sandboxという仕組みで制限をかけている。
2.4 JSON-RPCとは
JSON形式でリモートプロシージャコール(遠隔手続き呼び出し)を行う軽量プロトコル。MCPの通信はすべてJSON-RPCで行われる。
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": { "name": "get-time", "arguments": {} },
"id": 1
}
これらの用語が押さえられたら、MCP Appsの背景を見ていこう。
3. MCP Appsが生まれた背景
3.1 従来のAIチャットの限界
MCPの登場により、AIは外部ツールを呼び出せるようになった。データベースを検索したり、GitHubにPRを作ったり。しかし、結果はすべてテキストで返ってきた。
これが辛い場面がある。
あなた: 「先月の売上データを見せて」
AI: 「以下が結果です。東京: 1,234万円、大阪: 987万円、
名古屋: 756万円、福岡: 543万円、札幌: 321万円...
(以下30行省略)」
あなた: 「東京だけフィルタして」
AI: 「東京の売上データです。1月: 102万円、2月: 98万円...」
あなた: 「それをグラフにして」
AI: 「申し訳ありませんが、グラフの描画はできません」
毎回テキストで依頼するのは、まるで電話越しに「もうちょっと右のセルを見て」と指示しているようなもの。非効率だ。
3.2 業界の課題:アプリの分断
2025年末の時点で、各社が独自の方法でAIにUI機能を持たせようとしていた。
| 企業/プロジェクト | アプローチ | 課題 |
|---|---|---|
| MCP-UI(コミュニティ) | MCPにUI拡張を追加 | 標準化されていない |
| OpenAI Apps SDK | ChatGPT専用のアプリ基盤 | ChatGPTでしか動かない |
| 各種AIツール | 独自実装 | 互換性ゼロ |
開発者は「Claude用」「ChatGPT用」「VS Code用」と、同じ機能を何度も作り直す必要があった。
3.3 標準化への合流
2025年11月、MCP公式ブログでMCP Appsが提案された。MCP-UIとOpenAI Apps SDKの知見を統合し、業界標準を目指す動きだ。
そして2026年1月26日、ついにリリース。Anthropic、OpenAI、Microsoft、Google DeepMind、Block、JetBrainsが同時にサポートを表明した。
一度書けば、どのAIクライアントでも動く。 これがMCP Appsの最大の価値だ。
背景がわかったところで、基本的な仕組みを見ていこう。
4. 基本概念と仕組み
4.1 MCP Apps = Tool + UI Resource
MCP Appsの核となる概念はシンプルだ。
MCP Apps = 従来のMCPツール + UIリソース(HTML/JavaScript)
従来のMCPツールがテキストだけ返していたのに対し、MCP Appsは「このUIも一緒に表示してね」というメタデータを追加する。
| 要素 | 役割 | 具体例 |
|---|---|---|
| Tool | AIから呼び出される処理本体 | サーバー時刻の取得、DB検索 |
| UI Resource | ツール結果を表示するHTML | ダッシュボード、フォーム、グラフ |
_meta.ui |
ToolとUI Resourceを紐づけるメタデータ | { resourceUri: "ui://..." } |
4.2 通信フロー
データの流れを段階的に追ってみよう。
① ユーザー → AIクライアント: 「サーバー時刻を教えて」
② AIクライアント → MCPサーバー: Tool呼び出し(get-time)
③ MCPサーバー → AIクライアント: 結果 + _meta.ui情報
④ AIクライアント: _meta.uiを検知 → UI Resourceを取得
⑤ AIクライアント → 画面: sandboxed iframeでUIをレンダリング
⑥ iframe内のUI ←→ AIクライアント: JSON-RPC over postMessage
重要なのは⑥だ。iframe内のUIは直接MCPサーバーと通信できない。必ずAIクライアント(ホスト)を経由する。これはセキュリティのための設計だ。
4.3 セキュリティモデル
「外部サーバーのコードをiframeで実行する」と聞くと不安になるかもしれない。MCP Appsは複数のセキュリティレイヤーを持っている。
| レイヤー | 仕組み | 効果 |
|---|---|---|
| iframe sandbox |
allow-scripts allow-forms のみ許可 |
ネットワークアクセス不可、親ページDOMアクセス不可 |
| テンプレート事前審査 | ホストがHTMLをレンダリング前に検証 | 不審なスクリプトをブロック |
| 監査可能な通信 | 全通信がJSON-RPCで記録可能 | 何が起きたか追跡可能 |
| ユーザー同意 | UI発のツール呼び出しに承認を要求 | 意図しない操作を防止 |
基本概念が理解できたところで、実際にコードを書いて動かしてみよう。
5. 実践:MCP Appサーバーを作ってみよう
5.1 環境構築
以下のツールが必要だ。
# Node.js 18以上の確認
node --version
# v18.0.0 以上であることを確認
# プロジェクトの作成
mkdir my-mcp-app && cd my-mcp-app
npm init -y
# 依存パッケージのインストール
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors
npm install -D typescript vite vite-plugin-singlefile @types/express @types/cors @types/node tsx concurrently cross-env
5.2 環境別の設定ファイル
以下の3種類の設定ファイルを用意した。用途に応じて選択してほしい。
開発環境用(config.development.json)
{
"server": {
"port": 3001,
"host": "localhost",
"cors": {
"origin": "*",
"credentials": false
}
},
"mcp": {
"serverName": "my-mcp-app-dev",
"version": "1.0.0-dev",
"logLevel": "debug",
"enableHotReload": true
},
"ui": {
"bundleMode": "development",
"sourcemap": "inline",
"minify": false
}
}
本番環境用(config.production.json)
{
"server": {
"port": 8080,
"host": "0.0.0.0",
"cors": {
"origin": "https://your-domain.com",
"credentials": true
}
},
"mcp": {
"serverName": "my-mcp-app",
"version": "1.0.0",
"logLevel": "warn",
"enableHotReload": false
},
"ui": {
"bundleMode": "production",
"sourcemap": false,
"minify": true
}
}
テスト環境用(config.test.json)
{
"server": {
"port": 3099,
"host": "localhost",
"cors": {
"origin": "*",
"credentials": false
}
},
"mcp": {
"serverName": "my-mcp-app-test",
"version": "1.0.0-test",
"logLevel": "error",
"enableHotReload": false
},
"ui": {
"bundleMode": "test",
"sourcemap": false,
"minify": false
}
}
5.3 TypeScript設定
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src", "server.ts", "main.ts"]
}
// tsconfig.server.json - サーバー側コードのコンパイル用
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["server.ts", "main.ts"]
}
5.4 Vite設定(UIバンドル用)
// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: "dist",
emptyOutDir: false,
},
});
5.5 MCPサーバー本体(server.ts)
ここが本丸だ。ToolとUI Resourceの「二段構え登録」を行う。
// server.ts
import {
registerAppResource,
registerAppTool,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
const DIST_DIR = path.join(import.meta.dirname, "dist");
/**
* MCPサーバーインスタンスを生成する
* Tool と UI Resource を登録して返す
*/
export function createServer(): McpServer {
const server = new McpServer({
name: "Quickstart MCP App Server",
version: "1.0.0",
});
// UI ResourceのURI(ToolとResourceを紐づけるキー)
const resourceUri = "ui://get-time/mcp-app.html";
// --- ① Toolの登録 ---
// _meta.ui.resourceUri でUIリソースと紐づける
registerAppTool(
server,
"get-time",
{
title: "Get Time",
description: "Returns the current server time.",
inputSchema: {},
_meta: { ui: { resourceUri } },
},
async () => {
const time = new Date().toISOString();
return { content: [{ type: "text", text: time }] };
},
);
// --- ② UI Resourceの登録 ---
// Viteでバンドルした単一HTMLファイルを返す
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8"
);
return {
contents: [
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
return server;
}
5.6 エントリーポイント(main.ts)
stdioとHTTPの両方のトランスポートに対応する。
// main.ts
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
StreamableHTTPServerTransport
} from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";
/**
* HTTP経由でMCPサーバーを起動する
*/
async function startStreamableHTTPServer(
createServer: () => McpServer,
): Promise<void> {
const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
app.listen(port, () => {
console.log(`MCP server listening on http://localhost:${port}/mcp`);
});
}
/**
* stdio経由でMCPサーバーを起動する(Claude Desktop等向け)
*/
async function startStdioServer(
createServer: () => McpServer,
): Promise<void> {
await createServer().connect(new StdioServerTransport());
}
// 起動モード選択
async function main() {
if (process.argv.includes("--stdio")) {
await startStdioServer(createServer);
} else {
await startStreamableHTTPServer(createServer);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
5.7 UIの実装(View)
まずHTMLファイルを作成する。
<!-- mcp-app.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>Get Time App</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
padding: 16px;
background: #f8f9fa;
color: #333;
}
.container {
max-width: 400px;
margin: 0 auto;
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.time-display {
font-size: 1.5em;
font-weight: bold;
color: #2563eb;
margin: 12px 0;
}
button {
background: #2563eb;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
}
button:hover { background: #1d4ed8; }
</style>
</head>
<body>
<div class="container">
<h2>Server Time</h2>
<p class="time-display" id="server-time">Loading...</p>
<button id="get-time-btn">Refresh Time</button>
</div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>
次に、UIのロジックを実装する。
// src/mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";
// DOM要素の取得
const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;
// Appインスタンスの生成
const app = new App({ name: "Get Time App", version: "1.0.0" });
// --- ツール結果の受信ハンドラ ---
// app.connect() より前に設定すること(初回結果を逃さないため)
app.ontoolresult = (result) => {
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time
? new Date(time).toLocaleString("ja-JP")
: "[ERROR]";
};
// --- ボタンクリックで再取得 ---
getTimeBtn.addEventListener("click", async () => {
serverTimeEl.textContent = "Loading...";
try {
const result = await app.callServerTool({
name: "get-time",
arguments: {},
});
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time
? new Date(time).toLocaleString("ja-JP")
: "[ERROR]";
} catch (err) {
serverTimeEl.textContent = `Error: ${err}`;
}
});
// ホストとの接続を開始
app.connect();
5.8 package.json のスクリプト設定
{
"type": "module",
"scripts": {
"build": "tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build",
"start": "concurrently 'cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch' 'tsx watch main.ts'",
"start:stdio": "npm run build && node --loader tsx main.ts --stdio",
"test": "cross-env NODE_ENV=test tsx main.ts"
}
}
5.9 ビルドと実行
# ビルド
npm run build
# 実行結果
$ npm run build
> tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build
vite v6.x.x building for production...
✓ 1 module transformed.
dist/mcp-app.html 3.42 kB │ gzip: 1.21 kB
✓ built in 312ms
# 開発サーバー起動
npm start
# 出力
$ npm start
MCP server listening on http://localhost:3001/mcp
5.10 Claude Desktopでの動作確認
claude_desktop_config.json に以下を追加する。
{
"mcpServers": {
"my-mcp-app": {
"command": "node",
"args": ["--loader", "tsx", "/path/to/my-mcp-app/main.ts", "--stdio"]
}
}
}
Claude Desktopを再起動し、「今の時間を教えて」と聞いてみよう。テキストの回答に加え、インタラクティブなUIパネルが表示されるはずだ。
5.11 よくあるエラーと対処法
| エラー | 原因 | 対処法 |
|---|---|---|
Error: INPUT environment variable is not set |
Viteビルド時に環境変数INPUTが未設定 |
cross-env INPUT=mcp-app.html vite build で明示的に指定 |
ERR_MODULE_NOT_FOUND: Cannot find module './server.js' |
TypeScriptの出力先パス不一致 |
tsconfig.server.json の outDir を確認。importで .js 拡張子を使っているか確認 |
TypeError: Cannot read properties of null (reading 'textContent') |
DOM要素のIDがHTMLと不一致 |
mcp-app.html の id 属性と mcp-app.ts の getElementById 引数を一致させる |
CORS error: Access-Control-Allow-Origin |
HTTP経由でのCORS設定不足 |
cors() ミドルウェアが app.use(cors()) で正しく適用されているか確認 |
net::ERR_CONNECTION_REFUSED on port 3001 |
サーバーが起動していない、またはポート競合 |
lsof -i :3001 で他プロセスを確認。ポートを変更する場合は PORT=3002 npm start
|
| UIが真っ白のまま表示されない |
dist/mcp-app.html が存在しない |
npm run build を実行してから起動。dist/ ディレクトリにファイルがあるか確認 |
5.12 環境診断スクリプト
問題が発生した場合は、以下のスクリプトで環境を診断できる。
#!/bin/bash
# check_env.sh - MCP Apps 環境診断スクリプト
# 実行方法: bash check_env.sh
echo "=== MCP Apps Environment Check ==="
echo ""
# Node.js バージョン確認
NODE_VERSION=$(node --version 2>/dev/null)
if [ $? -ne 0 ]; then
echo "[NG] Node.js がインストールされていません"
else
MAJOR=$(echo $NODE_VERSION | cut -d. -f1 | tr -d 'v')
if [ "$MAJOR" -ge 18 ]; then
echo "[OK] Node.js $NODE_VERSION"
else
echo "[NG] Node.js 18以上が必要です(現在: $NODE_VERSION)"
fi
fi
# npm バージョン確認
NPM_VERSION=$(npm --version 2>/dev/null)
if [ $? -ne 0 ]; then
echo "[NG] npm がインストールされていません"
else
echo "[OK] npm $NPM_VERSION"
fi
# TypeScript確認
TSC_VERSION=$(npx tsc --version 2>/dev/null)
if [ $? -ne 0 ]; then
echo "[NG] TypeScript が見つかりません(npm install -D typescript)"
else
echo "[OK] $TSC_VERSION"
fi
# 必須パッケージ確認
echo ""
echo "--- Package Check ---"
PACKAGES=(
"@modelcontextprotocol/ext-apps"
"@modelcontextprotocol/sdk"
"express"
"vite"
)
for PKG in "${PACKAGES[@]}"; do
if [ -d "node_modules/$PKG" ]; then
echo "[OK] $PKG"
else
echo "[NG] $PKG が見つかりません(npm install $PKG)"
fi
done
# distディレクトリ確認
echo ""
echo "--- Build Check ---"
if [ -f "dist/mcp-app.html" ]; then
SIZE=$(wc -c < "dist/mcp-app.html")
echo "[OK] dist/mcp-app.html ($SIZE bytes)"
else
echo "[NG] dist/mcp-app.html が存在しません(npm run build を実行してください)"
fi
# ポート確認
echo ""
echo "--- Port Check ---"
PORT=${PORT:-3001}
if lsof -i ":$PORT" > /dev/null 2>&1; then
echo "[WARN] ポート $PORT は既に使用中です"
lsof -i ":$PORT" | head -3
else
echo "[OK] ポート $PORT は空いています"
fi
echo ""
echo "=== Check Complete ==="
実装方法がわかったので、次は具体的なユースケースを見ていく。
6. ユースケース別ガイド
6.1 ユースケース1: リアルタイムシステムモニター
想定読者: インフラエンジニア、SREチーム
推奨構成: CPU・メモリ使用率をリアルタイムでダッシュボード表示
サンプルコード:
// server.ts(モニター用Tool)
import os from "node:os";
registerAppTool(
server,
"system-monitor",
{
title: "System Monitor",
description: "Shows real-time system metrics",
inputSchema: {},
_meta: { ui: { resourceUri: "ui://system-monitor/app.html" } },
},
async () => {
const metrics = {
cpuUsage: os.loadavg()[0],
totalMemory: os.totalmem(),
freeMemory: os.freemem(),
uptime: os.uptime(),
platform: os.platform(),
hostname: os.hostname(),
};
return {
content: [{
type: "text",
text: JSON.stringify(metrics),
}],
};
},
);
// src/mcp-app.ts(モニターUI)
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "System Monitor", version: "1.0.0" });
app.ontoolresult = (result) => {
const text = result.content?.find((c) => c.type === "text")?.text;
if (!text) return;
const metrics = JSON.parse(text);
// メモリ使用率をプログレスバーで表示
const memUsed = metrics.totalMemory - metrics.freeMemory;
const memPercent = ((memUsed / metrics.totalMemory) * 100).toFixed(1);
document.getElementById("cpu")!.textContent =
`${metrics.cpuUsage.toFixed(2)}`;
document.getElementById("memory-bar")!.style.width = `${memPercent}%`;
document.getElementById("memory-text")!.textContent = `${memPercent}%`;
document.getElementById("uptime")!.textContent =
`${Math.floor(metrics.uptime / 3600)}h ${Math.floor((metrics.uptime % 3600) / 60)}m`;
};
// 5秒ごとに自動更新
setInterval(async () => {
await app.callServerTool({ name: "system-monitor", arguments: {} });
}, 5000);
app.connect();
6.2 ユースケース2: インタラクティブなDB検索ダッシュボード
想定読者: バックエンドエンジニア、データアナリスト
推奨構成: SQLクエリ結果をテーブル+フィルタ付きUIで表示
サンプルコード:
// server.ts(DB検索用Tool)
registerAppTool(
server,
"query-users",
{
title: "Query Users",
description: "Search users with interactive filters",
inputSchema: {
type: "object",
properties: {
region: { type: "string", description: "Filter by region" },
},
},
_meta: { ui: { resourceUri: "ui://query-users/app.html" } },
},
async ({ region }) => {
// 実際にはDBクエリを実行する
const users = [
{ id: 1, name: "田中太郎", region: "東京", revenue: 1234000 },
{ id: 2, name: "鈴木花子", region: "大阪", revenue: 987000 },
{ id: 3, name: "佐藤次郎", region: "東京", revenue: 756000 },
{ id: 4, name: "高橋美咲", region: "福岡", revenue: 543000 },
];
const filtered = region
? users.filter((u) => u.region === region)
: users;
return {
content: [{ type: "text", text: JSON.stringify(filtered) }],
};
},
);
// src/mcp-app.ts(検索UI)
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "User Query", version: "1.0.0" });
const tableBody = document.getElementById("table-body")!;
const regionFilter = document.getElementById("region-filter") as HTMLSelectElement;
function renderTable(users: any[]) {
tableBody.innerHTML = users
.map(
(u) => `<tr>
<td>${u.id}</td>
<td>${u.name}</td>
<td>${u.region}</td>
<td>${(u.revenue / 10000).toFixed(1)}万円</td>
</tr>`
)
.join("");
// AIのコンテキストにも反映(会話の文脈を維持)
app.updateModelContext({
content: [{
type: "text",
text: `現在 ${users.length} 件のユーザーを表示中`,
}],
});
}
// ツール結果の受信
app.ontoolresult = (result) => {
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) renderTable(JSON.parse(text));
};
// フィルタ変更時にサーバーツールを再呼び出し
regionFilter.addEventListener("change", async () => {
const result = await app.callServerTool({
name: "query-users",
arguments: { region: regionFilter.value || undefined },
});
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) renderTable(JSON.parse(text));
});
app.connect();
6.3 ユースケース3: 設定ウィザード(デプロイ設定)
想定読者: DevOpsエンジニア、プラットフォームチーム
推奨構成: 環境選択に応じて動的にフォームが変化するウィザードUI
サンプルコード:
// server.ts(デプロイ設定Tool)
registerAppTool(
server,
"deploy-config",
{
title: "Deploy Configuration Wizard",
description: "Interactive deployment configuration",
inputSchema: {
type: "object",
properties: {
environment: {
type: "string",
enum: ["production", "staging", "development"],
},
region: { type: "string" },
enableSSL: { type: "boolean" },
},
},
_meta: { ui: { resourceUri: "ui://deploy-config/app.html" } },
},
async (args) => {
// 環境に応じたデフォルト設定を返す
const defaults: Record<string, object> = {
production: {
replicas: 3, ssl: true, logging: "warn",
healthCheck: "/health", timeout: 30,
},
staging: {
replicas: 2, ssl: true, logging: "info",
healthCheck: "/health", timeout: 60,
},
development: {
replicas: 1, ssl: false, logging: "debug",
healthCheck: "/", timeout: 120,
},
};
const env = args.environment || "development";
return {
content: [{
type: "text",
text: JSON.stringify({ environment: env, ...defaults[env] }),
}],
};
},
);
// src/mcp-app.ts(ウィザードUI)
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Deploy Wizard", version: "1.0.0" });
const envSelect = document.getElementById("env-select") as HTMLSelectElement;
const configPanel = document.getElementById("config-panel")!;
function renderConfig(config: any) {
configPanel.innerHTML = `
<div class="config-item">
<label>Environment</label>
<span class="badge">${config.environment}</span>
</div>
<div class="config-item">
<label>Replicas</label>
<span>${config.replicas}</span>
</div>
<div class="config-item">
<label>SSL</label>
<span>${config.ssl ? "Enabled" : "Disabled"}</span>
</div>
<div class="config-item">
<label>Log Level</label>
<span>${config.logging}</span>
</div>
<div class="config-item">
<label>Health Check</label>
<code>${config.healthCheck}</code>
</div>
<div class="config-item">
<label>Timeout</label>
<span>${config.timeout}s</span>
</div>
`;
}
app.ontoolresult = (result) => {
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) renderConfig(JSON.parse(text));
};
envSelect.addEventListener("change", async () => {
const result = await app.callServerTool({
name: "deploy-config",
arguments: { environment: envSelect.value },
});
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) renderConfig(JSON.parse(text));
});
app.connect();
ユースケースを把握できたところで、この先の学習パスを確認しよう。
7. 学習ロードマップ
この記事を読んだ後、次のステップとして以下をおすすめする。
初級者向け(まずはここから)
- MCP公式チュートリアル -- MCPサーバーの基本を学ぶ
- MCP Apps Quickstart -- 本記事で扱った公式Quickstart
- ext-apps リポジトリのbasic-server-vanillajs例 -- ホスト通信やテーマ対応を学ぶ
中級者向け(実践に進む)
- Reactを使ったMCP App構築 -- basic-server-react例
- 3D可視化の実装 -- threejs-server例
- 地図アプリの構築 -- map-server例
- Vue / Svelte / Solid 等、好みのフレームワークでの実装
上級者向け(さらに深く)
- MCP Apps仕様書 -- プロトコルの内部構造を読む
- 独自のMCPクライアント(ホスト)を実装してMCP Appsをレンダリングする
- 本番運用に向けたセキュリティ監査とパフォーマンスチューニング
8. まとめ
この記事では、MCP Appsについて以下を解説した。
- MCP Appsとは何か: AIチャット内にインタラクティブUIを埋め込むMCPの公式拡張
- なぜ今注目されるか: Anthropic、OpenAI、Microsoftが合流した業界標準であること
- 仕組み: Tool + UI Resource の二段構え登録で、sandboxed iframeにUIを表示
- 実装方法: TypeScriptによる完全なサーバー+UI実装をコード付きで解説
- 実用パターン: モニター、DBダッシュボード、設定ウィザードの3つのユースケース
私の所感
正直に言うと、MCP Appsのインパクトは想像以上だった。
2025年にMCPが登場したとき、「便利だけどテキストだけか」と思っていた。MCP Appsはその制約を取り払った。テキスト→UIへの進化は、CLIからGUIへの進化と同じ種類のジャンプだ。
特に注目すべきは**「一度書けばどこでも動く」**という点。Claude、ChatGPT、VS Code、Goose... 主要なAIクライアントがすべて同じMCP Appsをサポートする。Webの世界で「Write once, run anywhere」が実現するまでどれだけ苦労したか考えると、AI業界は驚くほど早くこの段階に到達したと感じる。
一方で、私がハマったポイントも共有しておく。app.ontoolresult のハンドラ設定は app.connect() より前にやらないと、初回のツール結果を取りこぼす。公式ドキュメントにも書いてあるが、最初見落として30分溶かした。こういう「順序が大事」な罠は、テキストだけ読んでも気づきにくい。
MCP Appsはまだリリースされて間もない技術だ。今のうちに手を動かしておくと、「あの頃から触ってた」と言えるようになるかもしれない。