既存の MCP サーバーを MCP Apps 対応させて、Claude Desktop にインタラクティブ UI を表示してみた

既存の MCP サーバーを MCP Apps 対応させて、Claude Desktop にインタラクティブ UI を表示してみた

2026.03.19

はじめに

こんにちは、マッハチームの日吉です。

https://dev.classmethod.jp/articles/growthpack-mcp/
以前の記事で、自社プロダクト「グロースパック for LINE」のドキュメントを提供する MCP サーバーを作った話を紹介しました。(注目の記事になってて嬉しい)

今回はその MCP サーバーを MCP Apps に対応させ、Claude Desktop のチャット上にインタラクティブな UI を直接表示できるようにした話です。

実装の流れ

1. 依存の追加

pnpm add @modelcontextprotocol/ext-apps
pnpm add -D vite vite-plugin-singlefile

2. capabilities にリソースを追加

const server = new Server(
  { name: "growthpack-context-mcp", version: "1.0.0" },
  {
    capabilities: {
      tools: {},
      resources: {}, // 追加
    },
  }
);

3. UI リソースを定義

ui:// スキームでリソースを登録し、resources/listresources/read に応答できるようにします。

const UI_RESOURCES = [
  { uri: "ui://growthpack/compare", name: "機能比較ダッシュボード", file: "compare.html", bundled: true },
  { uri: "ui://growthpack/matching-wizard", name: "機能マッチング・ウィザード", file: "matching-wizard.html", bundled: true },
  { uri: "ui://growthpack/case-studies", name: "導入事例ブラウザ", file: "case-studies.html", bundled: true },
  { uri: "ui://growthpack/proposal-check", name: "提案条件チェッカー", file: "proposal-check.html", bundled: true },
];

// バンドル済み HTML の読み込み先
const BUNDLED_UI_DIR = path.resolve(__dirname, "../dist-ui/views");

function readUiHtml(resource: { file: string; bundled: boolean }): string {
  const dir = resource.bundled ? BUNDLED_UI_DIR : STATIC_UI_DIR;
  return fs.readFileSync(path.join(dir, resource.file), "utf-8");
}

// リソース一覧
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: UI_RESOURCES.map((r) => ({
    uri: r.uri,
    name: r.name,
    mimeType: "text/html;profile=mcp-app",
    _meta: { ui: { prefersBorder: true } },
  })),
}));

// リソース読み取り
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const resource = UI_RESOURCES.find((r) => r.uri === request.params.uri);
  if (!resource) throw new Error(`Unknown resource: ${request.params.uri}`);

  return {
    contents: [{
      uri: resource.uri,
      mimeType: "text/html;profile=mcp-app",
      text: readUiHtml(resource), // バンドル済み HTML
      _meta: { ui: { prefersBorder: true } },
    }],
  };
});

MIME タイプ text/html;profile=mcp-app がポイントです。これにより、ホストはこのリソースが MCP Apps の UI であることを認識します。

4. ツールに _meta.ui を付与

既存のツール定義に _meta.ui.resourceUri を追加するだけです。

{
  name: "compare_features",
  description: "複数機能を比較表で表示します",
  inputSchema: { /* 既存のまま */ },
  _meta: {
    ui: {
      resourceUri: "ui://growthpack/compare",
    },
  },
}

_meta.ui を理解しないホストでは単に無視されるため、後方互換性が保たれます。

5. UI(HTML)の作成 — SDK バンドルが必須

ここが最大のハマりポイントでした。MCP Apps SDK はビルド時にバンドルする必要があります

Vite エントリ用の HTML と TypeScript クライアントを分離して作成します。

<!-- views/case-studies.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <style>/* スタイル定義 */</style>
</head>
<body>
  <h1>導入事例ブラウザ</h1>
  <div id="result"></div>
  <script type="module" src="/views/case-studies-client.ts"></script>
</body>
</html>
// views/case-studies-client.ts
import { App } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "case-studies-browser", version: "1.0.0" });

// ツール呼び出し時の入力値を受け取る
app.ontoolinput = (params) => {
  const a = params.arguments || {};
  if (a.industry) document.getElementById("industry").value = a.industry;
};

// ツール結果を受け取って描画
app.ontoolresult = (params) => {
  if (params.content?.[0]?.text) renderResults(params.content[0].text);
};

// UI から直接 MCP ツールを呼び出すことも可能
async function doSearch() {
  const result = await app.callServerTool({
    name: "search_case_studies",
    arguments: { industry: "アパレル" },
  });
  renderResults(result.content?.[0]?.text || "");
}

// ホストに接続 — これが auto-resize のトリガー
await app.connect();

app.connect() の呼び出しが重要です。これにより iframe の auto-resize が動作し、UI が正しい高さで表示されます。詳しくは後述の「ハマったポイント」で解説します。

6. Vite でバンドル

vite-plugin-singlefile を使い、CSS・JS をすべて HTML にインライン化した単一ファイルを生成します。

// vite.config.ts
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
import path from "node:path";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist-ui",
    emptyOutDir: false,
    rollupOptions: {
      input: path.resolve(import.meta.dirname!, process.env.VIEW!),
    },
  },
});

vite-plugin-singlefile はコード分割を無効化するため、複数入力の同時ビルドに対応していません。各ビューを順番にビルドするスクリプトを用意します。

{
  "scripts": {
    "build:ui": "rm -rf dist-ui && for f in views/compare.html views/matching-wizard.html views/case-studies.html views/proposal-check.html; do VIEW=$f vite build; done",
    "build:all": "pnpm run build:ui && pnpm run build"
  }
}

7. HTTP モードの実装

Claude Desktop から Streamable HTTP 経由で接続するための HTTP モードを実装します。

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import cors from "cors";

const app = express();
app.use(cors());
app.use(express.json());

app.all("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  try { await server.close(); } catch {}
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3001, () => {
  console.log("MCP server listening on http://localhost:3001/mcp");
});

cloudflared でトンネルを作成し、Claude Desktop から接続します。

# ビルド & 起動
pnpm run build:all
node dist/index.js --http

# 別ターミナルでトンネル作成
cloudflared tunnel --url http://localhost:3001
# → https://xxxxx.trycloudflare.com が発行される

Claude Desktop の Settings → MCP Servers で以下の URL を追加します。

https://xxxxx.trycloudflare.com/mcp

作った UI

今回、4 つの UI を作成しました。いずれも既存のツールに UI を被せた形です。

機能比較ダッシュボード

compare_features ツールに対応。チェックボックスで機能を選択すると、比較表が動的に生成されます。callServerToollist_featurescompare_features を呼び出します。

  • 「会員証とスタンプの機能を比較して」
    スクリーンショット 2026-03-19 0.46.11

機能マッチング・ウィザード

find_matching_features ツールに対応。業種ドロップダウン、オフライン店舗チェックボックス、課題タグの入力フォームから、おすすめ機能がスコア付きカードで表示されます。

  • 「会員獲得が課題の顧客に合う機能を探して」
    スクリーンショット 2026-03-19 0.44.14

導入事例ブラウザ

search_case_studies ツールに対応。業種とキーワードでフィルタリングし、事例カードをグリッド表示。カードをクリックすると概要が展開されます。初期表示時に自動で全事例を読み込みます。

  • 「アパレル業界の導入事例を見せて」
    スクリーンショット 2026-03-19 0.44.58

提案条件チェッカー

get_proposal_conditions ツールに対応。機能を選択すると、提案推奨条件(緑)と提案 NG 条件(赤)がビジュアルに表示されます。

  • 「会員証の提案条件を確認したい」
    スクリーンショット 2026-03-19 0.47.14

ファイル構成

追加・変更したファイルは以下の通りです。

growthpack-context-mcp/
├── src/
│   └── index.ts              # resources capability + リソースハンドラ + _meta.ui + HTTP モード追加
├── views/                    # Vite エントリ(ソース)
│   ├── compare.html
│   ├── compare-client.ts
│   ├── matching-wizard.html
│   ├── matching-wizard-client.ts
│   ├── case-studies.html
│   ├── case-studies-client.ts
│   ├── proposal-check.html
│   └── proposal-check-client.ts
├── dist-ui/                  # Vite ビルド成果物(バンドル済み HTML)
│   └── views/
│       ├── compare.html
│       ├── matching-wizard.html
│       ├── case-studies.html
│       └── proposal-check.html
├── vite.config.ts            # Vite + vite-plugin-singlefile 設定
└── package.json              # build:ui / build:all スクリプト追加

既存のツールロジックやドキュメント取得部分は変更していません。

Claude Desktop での使い方

接続

cloudflared でトンネルを作成し、Claude Desktop から Streamable HTTP で接続します。

# サーバー起動
pnpm run build:all
node dist/index.js --http

# トンネル作成(別ターミナル)
cloudflared tunnel --url http://localhost:3001

Claude Desktop の Settings → MCP Servers で、表示されたトンネル URL + /mcp を登録します。

ハマったポイント

SDK を CDN からインポートすると動作しない

当初は esm.sh から SDK を CDN インポートしていましたが、iframe 内で t.custom is not a function というエラーが発生し、SDK が初期化されませんでした。

<!-- これは動作しない -->
<script type="module">
  import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.2.2";
  const app = new App({ name: "my-app" });
  await app.connect(); // ← ここに到達しない
</script>

原因は、@modelcontextprotocol/ext-apps が内部で zod を使用しており、esm.sh での動的解決時にサーバーサイド向けのコードがブラウザに読み込まれてしまうためです。

解決策: Vite + vite-plugin-singlefile で SDK をビルド時にバンドルし、HTML にインライン化する。これにより外部ドメインへの依存がなくなり、CSP の設定も不要になります。

app.connect() を呼ばないと UI が表示されない

SDK の初期化に失敗すると app.connect() が実行されず、iframe の auto-resize が動作しません。Claude Desktop は二重 iframe 構造(外側のプロキシ iframe → 内側のコンテンツ iframe)で UI を表示しますが、auto-resize が動かないと iframe の高さが 0 のままになります。

結果として、HTML は正しく読み込まれているのに画面には何も表示されない という状況になります。DevTools の Elements パネルで iframe 内に HTML が存在していることは確認できるのに、目に見えないという厄介な症状です。

CSP はリソース側に設定する

CSP(Content Security Policy)の設定場所を間違えてしばらく悩みました。

  • ツール側の _meta.ui: resourceUri のみを指定する
  • リソース側の _meta.ui: csp.connectDomains / csp.resourceDomains を指定する
// ツール側 — resourceUri のみ
_meta: { ui: { resourceUri: "ui://growthpack/compare" } }

// リソース側 — CSP はここに(外部ドメインにアクセスする場合のみ)
_meta: { ui: { prefersBorder: true, csp: { connectDomains: ["https://api.example.com"] } } }

なお、SDK をバンドル済みで外部ドメインへのアクセスが不要な場合は、CSP の設定自体が不要です。

まとめ

既存の MCP サーバーを MCP Apps に対応させるのに必要な変更は、大きく 4 つです。

  1. capabilities に resources: {} を追加
  2. ui:// リソースのハンドラを実装resources/list / resources/read
  3. ツール定義に _meta.ui.resourceUri を追加
  4. UI の HTML を Vite でバンドル(SDK の CDN インポートは動作しない)

既存のツールロジックには手を加える必要がありません。特に重要な学びは以下の 4 点です。

  1. SDK は CDN ではなくバンドルする — esm.sh 等からの動的インポートは zod の依存関係問題でブラウザで動作しない。Vite + vite-plugin-singlefile で単一 HTML にバンドルするのが確実
  2. app.connect() が auto-resize のトリガー — これが実行されないと iframe の高さが 0 になり、HTML が読み込まれていても表示されない
  3. CSP はリソース側に設定する — ツール側の _meta.ui には resourceUri のみ
  4. HTML は完全自己完結型にする — 外部 CDN への依存を排除し、すべてインラインにすることで CSP の設定も不要になる

MCP Apps はまだ新しい仕様ですが、テキスト応答しかできなかった MCP ツールにインタラクティブな操作性を加えられるのは大きな進化だと感じました。既存の MCP サーバーをお持ちの方は、まずは 1 つのツールから試してみてはいかがでしょうか。

さいごに

マッハチームではMCP Appsをはじめとした様々な発信を行っています!Xでも発信していきますので是非フォローしてください!
https://x.com/mach_asset_pro

この記事をシェアする

FacebookHatena blogX

関連記事