0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP Appsってなんだ?〜AIチャットにUIを埋め込む革命技術を完全理解〜

Posted at

この記事の対象読者

  • 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.jsonoutDir を確認。importで .js 拡張子を使っているか確認
TypeError: Cannot read properties of null (reading 'textContent') DOM要素のIDがHTMLと不一致 mcp-app.htmlid 属性と mcp-app.tsgetElementById 引数を一致させる
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. 学習ロードマップ

この記事を読んだ後、次のステップとして以下をおすすめする。

初級者向け(まずはここから)

  1. MCP公式チュートリアル -- MCPサーバーの基本を学ぶ
  2. MCP Apps Quickstart -- 本記事で扱った公式Quickstart
  3. ext-apps リポジトリのbasic-server-vanillajs例 -- ホスト通信やテーマ対応を学ぶ

中級者向け(実践に進む)

  1. Reactを使ったMCP App構築 -- basic-server-react例
  2. 3D可視化の実装 -- threejs-server例
  3. 地図アプリの構築 -- map-server例
  4. Vue / Svelte / Solid 等、好みのフレームワークでの実装

上級者向け(さらに深く)

  1. MCP Apps仕様書 -- プロトコルの内部構造を読む
  2. 独自のMCPクライアント(ホスト)を実装してMCP Appsをレンダリングする
  3. 本番運用に向けたセキュリティ監査とパフォーマンスチューニング

8. まとめ

この記事では、MCP Appsについて以下を解説した。

  1. MCP Appsとは何か: AIチャット内にインタラクティブUIを埋め込むMCPの公式拡張
  2. なぜ今注目されるか: Anthropic、OpenAI、Microsoftが合流した業界標準であること
  3. 仕組み: Tool + UI Resource の二段構え登録で、sandboxed iframeにUIを表示
  4. 実装方法: TypeScriptによる完全なサーバー+UI実装をコード付きで解説
  5. 実用パターン: モニター、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はまだリリースされて間もない技術だ。今のうちに手を動かしておくと、「あの頃から触ってた」と言えるようになるかもしれない。


参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?