mcpField
C
convert-web-app

convert-web-app

github.com/modelcontextprotocol開発3.0k976 回インストール1 日前に更新
リポジトリ

convert-web-app について

既存の Web アプリを単独のサイトとして維持しながら、MCP App にも対応させる開発者向けスキルです。描画ロジックを共有し、実行環境に応じてデータ取得元を切り替える方法を説明します。

主な機能

  1. 01単独 Web アプリの動作を維持
  2. 02MCP 実行環境を検出
  3. 03ビルド済み HTML を MCP リソース化
  4. 04アプリ表示用ツールを登録
  5. 05CSP オリジンを調査・設定

活用シーン

  1. 01既存サイトに MCP App モードを追加
  2. 02単独モードと MCP モードで UI を共有
  3. 03API データを MCP UI に接続

MCP 対応

Web アプリのビルド済み HTML をリソースとして登録し、表示用ツールを提供する MCP サーバーの作成方法を案内します。UI は MCP のライフサイクルから引数を読み取るか、サーバーツールを呼び出してデータを取得できます。

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/modelcontextprotocol/ext-apps --skill convert-web-app

インストール後、Claude Code で/convert-web-appを入力して読み込まれたか確認します。

詳細

Web アプリに MCP App サポートを追加する

既存の Web アプリに MCP App サポートを追加し、単一のコードベースから、スタンドアロンの Web アプリとしても、Claude Desktop などの MCP 対応ホスト内にインラインで表示される MCP App としても動作するようにします。

仕組み

既存の Web アプリはそのまま維持されます。薄い初期化レイヤーが、アプリが MCP ホスト内で実行されているか、通常の Web ページとして実行されているかを検出し、適切なソースからパラメーターを取得します。新しい MCP サーバーが、アプリのバンドル済み HTML をリソースとしてラップし、表示用のツールを登録します。

Standalone:  Browser loads page → App reads URL params / APIs → renders
MCP App:     Host calls tool → Server returns result → Host renders app in iframe → App reads MCP lifecycle → renders

アプリのレンダリングロジックは共通で、変わるのはデータソースだけです。

リファレンスコードの取得

SDK リポジトリをクローンして、動作するサンプルと API ドキュメントを参照します。

git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
API リファレンス(ソースファイル)

/tmp/mcp-ext-apps/src/ の JSDoc ドキュメントを直接確認します。

ファイル内容
src/app.tsApp クラス、ハンドラー(ontoolinput、ontoolresult、onhostcontextchanged、onteardown)、ライフサイクル
src/server/index.tsregisterAppTool、registerAppResource、ツールの表示オプション
src/spec.types.tsすべての型定義:McpUiHostContext、CSS 変数キー、表示モード
src/styles.tsapplyDocumentTheme、applyHostStyleVariables、applyHostFonts
src/react/useApp.tsxReact アプリ用の useApp フック
src/react/useHostStyles.tsuseHostStyles、useHostStyleVariables、useHostFonts フック
フレームワークのテンプレート

/tmp/mcp-ext-apps/examples/basic-server-{framework}/ を参考に、必要に応じて応用します。

テンプレート主なファイル
basic-server-vanillajs/server.ts、src/mcp-app.ts、mcp-app.html
basic-server-react/server.ts、src/mcp-app.tsx(useApp フックを使用)
basic-server-vue/server.ts、src/App.vue
basic-server-svelte/server.ts、src/App.svelte
basic-server-preact/server.ts、src/mcp-app.tsx
basic-server-solid/server.ts、src/mcp-app.tsx
リファレンスサンプル
サンプル関連するパターン
examples/map-server/外部 API 連携と CSP(connectDomains、resourceDomains)
examples/sheet-music-server/外部アセット(サウンドフォント)を読み込むライブラリ
examples/pdf-server/バイナリーコンテンツの処理と、アプリ専用のヘルパーツール

ステップ 1:既存の Web アプリを分析する

コードを書く前に、既存の Web アプリを調べ、変更が必要な箇所を計画します。

調査する項目
  1. データソース — アプリはどのようにデータを取得していますか?(URL パラメーター、API 呼び出し、props、ハードコード、localStorage)
  2. 外部依存関係 — CDN スクリプト、フォント、API エンドポイント、iframe 埋め込み、WebSocket 接続
  3. ビルドシステム — 現在のバンドラー(Webpack、Vite、Rollup、なし)、フレームワーク(React、Vue、バニラ)、エントリーポイント
  4. ユーザー操作 — ツールのパラメーターに対応させるべき入力やフォームはありますか?
  5. 実行環境の検出 — アプリが MCP ホスト内で実行されているか、どのように判定しますか?(例:現在のオリジン、クエリパラメーター、または window.parent !== window の確認)

調査結果をユーザーに提示し、方針を確認します。

データソースの対応付け

ハイブリッドモードでは、スタンドアロンでの使用時に既存のデータソースを維持し、MCP に対応する仕組みを追加します。

スタンドアロンのデータソースMCP App における対応
URL クエリパラメーターontoolinput / ontoolresult の arguments または structuredContent
REST API 呼び出しサーバー側ツールへの app.callServerTool()、または CSP の connectDomains を設定して直接 API を呼び出す
Props / コンポーネント入力ontoolinput の arguments
localStorage / sessionStorageサンドボックス化された iframe では利用できません。structuredContent またはサーバー側の状態を介して渡します
WebSocket 接続CSP の connectDomains を設定して維持するか、アプリ専用ツール経由のポーリングに切り替えます
ハードコードされたデータ動的にするには、ツールの structuredContent に移します

ステップ 2:CSP 要件を調査する

MCP Apps の HTML は、同一オリジンのサーバーがないサンドボックス化された iframe 内で実行されます。CSP では、すべての外部オリジンを宣言する必要があります。宣言されていないオリジンは、エラーが表示されないまま失敗します。

コードを書く前に、アプリをビルドし、参照しているすべてのオリジンを調査します。

  1. 既存のビルドコマンドを使ってアプリをビルドします
  2. 生成された HTML、CSS、JS を検索し、すべてのオリジンを確認します(「外部」オリジンだけでなく、ネットワークリクエストにはすべて CSP の許可が必要です)
  3. 見つかったオリジンごとに、ソースまでたどります。
    • 定数に由来する場合 → 共通(開発環境と本番環境で同じ)
    • 環境変数または条件分岐に由来する場合 → その仕組みを記録し、開発環境と本番環境の両方の値を特定します
  4. 独自にリクエストを行う可能性があるサードパーティーライブラリ(分析、エラートラッキングなど)を確認します

調査結果を記録し、次の 3 つのリストにまとめます。各オリジンについて、共通、開発環境のみ、本番環境のみのいずれかを記載します。

  • resourceDomains:画像、フォント、スタイル、スクリプトを提供するオリジン
  • connectDomains:API/fetch リクエスト先のオリジン
  • frameDomains:ネストされた iframe のオリジン

オリジンが見つからない場合、アプリにカスタム CSP ドメインは不要な可能性があります。

ステップ 3:MCP サーバーをセットアップする

ツールとリソースを登録する新しい MCP サーバーを作成します。これにより、既存のウェブアプリを MCP ホスト向けにラップします。

依存関係
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D tsx vite vite-plugin-singlefile

バージョンの解決には npm install を使用します。ext-apps 2.x では、^2.0.0 の分割されたベース MCP SDK パッケージが必要です(@modelcontextprotocol/core は推移的にインストールされます)。レガシー版の @modelcontextprotocol/sdk v1 パッケージを追加したり、未公開のローカルパッケージで代用したりしないでください。

サーバーコード

server.ts を作成します。

import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";

const server = new McpServer({ name: "my-app", version: "1.0.0" });

const resourceUri = "ui://my-app/mcp-app.html";

// Register the tool — inputSchema maps to the app's data sources
registerAppTool(server, "show-app", {
  description: "Displays the app with the given parameters",
  inputSchema: z.object({
    query: z.string().describe("The search query"),
  }),
  _meta: { ui: { resourceUri } },
}, async (args) => {
  // Process args server-side if needed
  return {
    content: [{ type: "text", text: `Showing app for: ${args.query}` }],
    structuredContent: { query: args.query },
  };
});

// Register the HTML resource
registerAppResource(
  server,
  "My App UI",
  resourceUri,
  {},
  async () => {
    const html = await fs.readFile(
      path.resolve(import.meta.dirname, "dist", "mcp-app.html"),
      "utf-8",
    );
    return {
      contents: [
        {
          uri: resourceUri,
          mimeType: RESOURCE_MIME_TYPE,
          text: html,
          // Add CSP domains from Step 2 if needed:
          // _meta: { ui: { csp: { connectDomains: ["https://api.example.com"], resourceDomains: ["https://cdn.example.com"] } } },
        },
      ],
    };
  },
);

// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);
パッケージスクリプト

package.json に追加します。

{
  "scripts": {
    "build:ui": "vite build",
    "build:server": "tsc",
    "build": "npm run build:ui && npm run build:server",
    "serve": "tsx server.ts"
  }
}

ステップ 4:ビルドパイプラインを調整する

MCP App のビルドでは、vite-plugin-singlefile を使って単一の HTML ファイルを生成する必要があります。スタンドアロンのウェブアプリのビルドは変更しません。

Vite の設定

vite.config.ts を作成または更新します。アプリがすでに Vite を使用している場合は、vite-plugin-singlefile と MCP App ビルド用の別のエントリーポイントを追加します。別のバンドラーを使用している場合は、MCP App ビルド専用の Vite 設定を追加します。

import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist",
    rollupOptions: {
      input: "mcp-app.html",
    },
  },
});

必要に応じて、フレームワーク固有の Vite プラグインを追加します(例:React 用の @vitejs/plugin-react、Vue 用の @vitejs/plugin-vue)。

HTML エントリーポイント

MCP App ビルド用の別のエントリーポイントとして mcp-app.html を作成します。同じアプリコードを指定できます。残りの処理はランタイム検出で行われます。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>MCP App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./src/main.ts"></script>
  </body>
</html>
2 段階のビルド
  1. Vite が UI をバンドルします → dist/mcp-app.html(すべてのアセットがインライン化された単一ファイル)
  2. サーバーを別途コンパイルします(TypeScript → JavaScript)

スタンドアロンのウェブアプリは、引き続き従来どおりビルドしてデプロイできます。

ステップ 5:既存のロジックに MCP App の初期化処理を追加する

ここが中心となるステップです。アプリのデータソースを置き換えるのではなく、MCP モード用の別の初期化経路を追加します。アプリは起動時に実行環境を検出し、適切なソースからパラメーターを読み取ります。

ハイブリッドパターン
import { App, PostMessageTransport } from "@modelcontextprotocol/ext-apps";

// Detect whether we're running inside an MCP host.
// Choose a detection method that fits the app:
//   - Origin check: window.location.origin !== 'https://myhost.com'
//   - Null origin (sandboxed iframe): window.location.origin === 'null'
//   - Query param: new URL(location.href).searchParams.has('mcp')
const isMcpApp = window.location.origin === "null";

async function getParameters(): Promise<Record<string, string>> {
  if (isMcpApp) {
    // Running as MCP App — get params from tool lifecycle
    const app = new App({ name: "My App", version: "1.0.0" });

    // Register handlers BEFORE connect()
    const params = await new Promise<Record<string, string>>((resolve) => {
      app.ontoolresult = (result) => resolve(result.structuredContent ?? {});
    });

    await app.connect(new PostMessageTransport());
    return params;
  } else {
    // Running as standalone web app — get params from URL
    return Object.fromEntries(new URL(location.href).searchParams);
  }
}

async function main() {
  const params = await getParameters();
  renderApp(params); // Same rendering logic for both modes
}

main().catch(console.error);
URL パラメーター(ハイブリッド)
// Before (standalone only):
const query = new URL(location.href).searchParams.get("q");
renderApp(query);

// After (hybrid):
async function getQuery(): Promise<string> {
  if (isMcpApp) {
    const app = new App({ name: "My App", version: "1.0.0" });
    return new Promise((resolve) => {
      app.ontoolinput = (params) => resolve(params.arguments?.q ?? "");
      app.connect(new PostMessageTransport());
    });
  }
  return new URL(location.href).searchParams.get("q") ?? "";
}

const query = await getQuery();
renderApp(query); // Unchanged rendering logic
API 呼び出し(ハイブリッド)
// Before (standalone only):
const data = await fetch("/api/data").then(r => r.json());

// After (hybrid):
async function fetchData(): Promise<any> {
  if (isMcpApp) {
    const result = await app.callServerTool({
      name: "fetch-data",
      arguments: {},
    });
    return result.structuredContent;
  }
  return fetch("/api/data").then(r => r.json());
}

または、両方のモードで API を直接呼び出し、CSP の connectDomains を設定します。

// API calls can stay unchanged if the API is external and the CSP declares the domain
// Declare connectDomains: ["api.example.com"] in the resource registration
localStorage / sessionStorage(ハイブリッド)
// Before (standalone only):
const saved = localStorage.getItem("settings");

// After (hybrid) — localStorage isn't available in sandboxed iframes:
function getSettings(): any {
  if (isMcpApp) {
    // Will be provided via tool result
    return null; // or a default
  }
  return JSON.parse(localStorage.getItem("settings") ?? "null");
}
ハイブリッドの完全な例
import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

const isMcpApp = window.location.origin === "null";

async function initMcpApp(): Promise<Record<string, any>> {
  const app = new App({ name: "My App", version: "1.0.0" });

  // Register ALL handlers BEFORE connect()
  const params = await new Promise<Record<string, any>>((resolve) => {
    app.ontoolinput = (input) => resolve(input.arguments ?? {});
  });

  app.onhostcontextchanged = (ctx) => {
    if (ctx.theme) applyDocumentTheme(ctx.theme);
    if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
    if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
    if (ctx.safeAreaInsets) {
      const { top, right, bottom, left } = ctx.safeAreaInsets;
      document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
    }
  };

  app.onteardown = async () => {
    return {};
  };

  await app.connect(new PostMessageTransport());
  return params;
}

async function initStandaloneApp(): Promise<Record<string, any>> {
  return Object.fromEntries(new URL(location.href).searchParams);
}

async function main() {
  const params = isMcpApp ? await initMcpApp() : await initStandaloneApp();
  renderApp(params); // Same rendering logic — no fork needed
}

main().catch(console.error);

ステップ 6:ホストのスタイル設定との連携を追加する(MCP モードのみ)

MCP App として実行する場合は、テーマの一貫性を保つためにホストのスタイル設定と連携します。CSS 変数のフォールバックを使用し、どちらのモードでもアプリが正しく表示されるようにします。

Vanilla JS — ヘルパー関数を使用します。

import { applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";

app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) applyDocumentTheme(ctx.theme);
  if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
  if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
};

React — フックを使用します。

import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";

const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);

CSS での変数の使用 — スタンドアロンモードでも適切に表示されるよう、var() にフォールバックを指定します。

.container {
  background: var(--color-background-secondary, #f5f5f5);
  color: var(--color-text-primary, #333);
  font-family: var(--font-sans, system-ui);
  border-radius: var(--border-radius-md, 8px);
}

主な変数グループ:--color-background-*、--color-text-*、--color-border-*、--font-sans、--font-mono、--font-text-*-size、--font-heading-*-size、--border-radius-*。一覧については src/spec.types.ts を参照してください。

任意の拡張

アプリ専用ヘルパーツール

UI がポーリングまたは取得する必要があり、モデルが直接呼び出す必要のないデータの場合:

registerAppTool(server, "refresh-data", {
  description: "Fetches latest data for the UI",
  _meta: { ui: { resourceUri, visibility: ["app"] } },
}, async () => {
  const data = await getLatestData();
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
});

UI は次のように呼び出します。 app.callServerTool({ name: "refresh-data", arguments: {} })。

ストリーミングによる部分入力

大きなツール入力の場合は、LLM の生成中に進捗を表示するため、ontoolinputpartial を使用します。

app.ontoolinputpartial = (params) => {
  const args = params.arguments; // Healed partial JSON - always valid
  renderPreview(args);
};

app.ontoolinput = (params) => {
  renderFull(params.arguments);
};
全画面モード
app.onhostcontextchanged = (ctx) => {
  if (ctx.availableDisplayModes?.includes("fullscreen")) {
    fullscreenBtn.style.display = "block";
  }
  if (ctx.displayMode) {
    container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen");
  }
};

async function toggleFullscreen() {
  const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
  const result = await app.requestDisplayMode({ mode: newMode });
  currentMode = result.mode;
}
テキストフォールバック

UI 非対応のホスト向けに、必ず content 配列を指定してください。

return {
  content: [{ type: "text", text: "Fallback description of the result" }],
  structuredContent: { /* data for the UI */ },
};

よくある間違いとその回避方法

  1. 外部オリジンの CSP 宣言を忘れる — サンドボックス化された iframe 内でエラーが表示されずに失敗します
  2. MCP モードで localStorage / sessionStorage を使用する — サンドボックス化された iframe では利用できません。フォールバックを使用するか、structuredContent を介して渡してください
  3. vite-plugin-singlefile を追加し忘れる — 外部アセットが iframe 内で読み込まれません
  4. connect() の後にハンドラーを登録する — app.connect() の呼び出し前に、すべてのハンドラーを登録してください
  5. フォールバックなしでスタイルをハードコードする — 両方のモードで正しく表示されるよう、ホストの CSS 変数を var(..., fallback) とともに使用してください
  6. セーフエリアのインセットを処理しない — onhostcontextchanged で必ず ctx.safeAreaInsets を適用してください
  7. テキストの content フォールバックを忘れる — UI 非対応のホスト向けに、必ず content 配列を指定してください
  8. リソースの登録を忘れる — ツールが参照する resourceUri に対応するリソースが必要です
  9. 既存ロジックを置き換えて分岐させない — 元のデータソースはそのまま維持し、それに加えて MCP の経路を追加してください

テスト

basic-host を使用する

basic-host のサンプルを使用して、MCP App モードをテストします。

# Terminal 1: Build and run your server
npm run build && npm run serve

# Terminal 2: Run basic-host (from cloned repo)
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080

SERVERS にサーバー URL の JSON 配列を設定します(デフォルト:http://localhost:3001/mcp)。

確認項目
  1. MCP モード:basic-host でアプリがコンソールエラーなしに読み込まれる
  2. ontoolinput ハンドラーがツール引数を受け取って実行される
  3. ontoolresult ハンドラーがツールの結果を受け取って実行される
  4. ホストのスタイル(テーマ、フォント、色)が正しく適用される
  5. 外部リソースが読み込まれる(CSP ドメインが設定されている場合)
  6. スタンドアロンモード:ブラウザーで直接開いてもアプリが動作する

コメント