add-app-to-server について
既存の MCP サーバーに MCP Apps SDK でインタラクティブな UI を追加する開発者向けスキルです。ツールの選定からリソース登録、UI のビルド、テストまで扱い、テキスト出力も維持します。
主な機能
- 01UI 化するツールの評価
- 02ツールと HTML リソースの連携
- 03テキストのみの出力を維持
- 04複数フレームワークでの UI 構築
- 05ホストスタイルと CSP の設定
活用シーン
- 01ツールのリストや指標を可視化
- 02ツール UI で地図や PDF を表示
- 03UI 専用のツールを追加
MCP 対応
MCP Apps SDK を使い、既存の MCP サーバーツールと HTML リソースを連携させる方法を案内します。ツール呼び出し時に MCP クライアントが UI を表示でき、テキストのみのクライアントにはテキスト結果を返せます。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ modelcontextprotocol/ ext-apps --skill add-app-to-serverインストール後、Claude Code で/add-app-to-serverを入力して読み込まれたか確認します。
詳細
MCP サーバーに UI を追加する
MCP Apps SDK(@modelcontextprotocol/ext-apps)を使って、既存の MCP サーバーのツールにインタラクティブな UI を追加します。
仕組み
既存のツールに、ホストの会話内にインライン表示される HTML リソースを組み合わせます。ツールはテキストのみのクライアントでも引き続き動作します。UI は機能を強化するものであり、置き換えるものではありません。UI のメリットがある各ツールを _meta.ui.resourceUri でリソースにリンクすると、ツールが呼び出されたときにホストがそのリソースをサンドボックス化された iframe に表示します。
参考コードの入手
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.ts | App クラス、ハンドラー(ontoolinput、ontoolresult、onhostcontextchanged、onteardown)、ライフサイクル |
src/server/index.ts | registerAppTool、registerAppResource、getUiCapability、ツールの表示オプション |
src/spec.types.ts | すべての型定義:McpUiHostContext、CSS 変数キー、表示モード |
src/styles.ts | applyDocumentTheme、applyHostStyleVariables、applyHostFonts |
src/react/useApp.tsx | React アプリ向けの useApp フック |
src/react/useHostStyles.ts | useAppStyles、useHostStyleVariables、useHostFonts フック |
主なサンプル(複数のツールパターン)
これらのサンプルでは、App 対応ツールと通常のツールの両方を備えたサーバーを紹介しています。今回追加するパターンそのものです。
| サンプル | パターン |
|---|---|
examples/map-server/ | show-map(App ツール)+ geocode(通常のツール) |
examples/pdf-server/ | display_pdf(App ツール)+ list_pdfs(通常のツール)+ read_pdf_bytes(App 専用ツール) |
examples/system-monitor-server/ | get-system-info(App ツール)+ poll-system-stats(App 専用のポーリングツール) |
フレームワークテンプレート
/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 |
ステップ 1:既存のツールを分析する
コードを書く前に、サーバーの既存ツールを分析し、どのツールに UI のメリットがあるかを判断します。
- サーバーのソースを読み、登録されているツールをすべて列挙します
- 各ツールについて、UI のメリットがあるか(視覚化できるデータを返す、ユーザーとのやり取りがあるなど)、テキストのみで問題ないか(単純な検索、ユーティリティ関数など)を評価します
- App 専用ヘルパーにできるツール(UI がポーリングまたは取得する必要があるが、モデルが直接呼び出す必要はないデータ)を特定します
- 分析結果をユーザーに提示し、どのツールを拡張するか確認します
判断基準
| ツールの出力タイプ | UI のメリット | 例 |
|---|---|---|
| 構造化データ/リスト/テーブル | 高 — インタラクティブなテーブル、検索、フィルタリング | アイテム一覧、検索結果 |
| 時系列のメトリクス/数値 | 高 — グラフ、ゲージ、ダッシュボード | システム統計、分析データ |
| メディア/リッチコンテンツ | 高 — ビューアー、プレーヤー、レンダラー | 地図、PDF、画像、動画 |
| 単純なテキスト/確認メッセージ | 低 — テキストで十分 | 「ファイルを作成しました」、「設定を更新しました」 |
| 他のツール向けのデータ | App 専用を検討 | ポーリング用エンドポイント、チャンクローダー |
ステップ 2:依存関係の追加
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/client@^2.0.0 @modelcontextprotocol/server@^2.0.0 zod@^4.2.0
npm install -D vite vite-plugin-singlefile
必要に応じてフレームワーク固有の依存関係(React の場合は react、react-dom、@vitejs/plugin-react など)と、サーバーが HTTP トランスポートを使用する場合は @modelcontextprotocol/node / @modelcontextprotocol/express も追加します。
ext-apps 2.x では、分割されたベース MCP SDK パッケージのバージョン ^2.0.0 が必要です
(@modelcontextprotocol/core は依存関係を介して追加されます)。引き続き
@modelcontextprotocol/sdk v1 をインポートしている既存のサーバーでは、App 統合を追加する前に
インポートとハンドラースキーマを移行する必要があります。
SDK v1(@modelcontextprotocol/sdk) | SDK v2 |
|---|---|
sdk/server/mcp.js(McpServer) | @modelcontextprotocol/server |
sdk/server/streamableHttp.js(StreamableHTTPServerTransport) | @modelcontextprotocol/node の NodeStreamableHTTPServerTransport |
| 手動での Express 接続 | @modelcontextprotocol/express の createMcpExpressApp |
sdk/server/stdio.js | @modelcontextprotocol/server/stdio |
sdk/types.js の型(CallToolResult、…) | @modelcontextprotocol/client または @modelcontextprotocol/server |
sdk/types.js の zod スキーマ(CallToolResultSchema、…) | @modelcontextprotocol/core |
生の zod 形状:inputSchema: { q: z.string() } | inputSchema: z.object({ q: z.string() }) |
ツールコールバック内の extra.signal | extra.mcpReq.signal |
setRequestHandler(SomeRequestSchema, handler) | setRequestHandler("some/method", { params: ParamsSchema }, handler)(2 引数形式は "tools/call" などの仕様上のメソッドに限ります) |
ステップ 3:ビルドパイプラインの設定
Vite の設定
vite-plugin-singlefile を使用して UI を単一の HTML ファイルにバンドルするため、vite.config.ts を作成します。
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: "mcp-app.html", // one per UI, or one shared entry
},
},
});
HTML エントリーポイント
mcp-app.html を作成します(ツールごとに異なるビューが必要な場合は、UI ごとに 1 つ作成します)。
<!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/mcp-app.ts"></script>
</body>
</html>
ビルドスクリプト
package.json にビルドスクリプトを追加します。サーバーコードをバンドルする前に、UI をビルドする必要があります。
{
"scripts": {
"build:ui": "vite build",
"build:server": "tsc",
"build": "npm run build:ui && npm run build:server",
"serve": "tsx server.ts"
}
}
ステップ 4:ツールを App ツールに変換
通常の MCP ツールを、UI を備えた App ツールに変換します。
変換前(通常の MCP ツール):
server.registerTool(
"my-tool",
{ inputSchema: z.object({ param: z.string() }) },
async ({ param }) => {
const data = await fetchData(param);
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
);
変換後(UI を備えた App ツール):
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppTool(
server,
"my-tool",
{
description: "Shows data with an interactive UI",
inputSchema: z.object({ param: z.string() }),
_meta: { ui: { resourceUri } },
},
async ({ param }) => {
const data = await fetchData(param);
return {
content: [{ type: "text", text: JSON.stringify(data) }], // text fallback for non-UI hosts
structuredContent: { data }, // structured data for the UI
};
},
);
重要なガイドライン:
- テキストのみを扱うクライアント向けのテキストフォールバックを含む
content配列は、必ず保持してください - UI の描画に必要なデータには
structuredContentを追加します _meta.ui.resourceUriを使って、ツールをそのリソースに関連付けます- UI の利点がないツールは変更せず、通常のツールのままにします
ステップ 5:リソースの登録
ホストが取得できるように、HTML リソースを登録します。
import fs from "node:fs/promises";
import path from "node:path";
const resourceUri = "ui://my-tool/mcp-app.html";
registerAppResource(
server,
"My Tool 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 }],
};
},
);
複数のツールで同じ UI を共有する場合は、同じ resourceUri とリソース登録を参照できます。
ステップ 6:UI の構築
ハンドラーの登録
app.connect() を呼び出す前に、すべてのハンドラーを登録します。
import { App, PostMessageTransport, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "My App", version: "1.0.0" });
app.ontoolinput = (params) => {
// Render the UI using params.arguments and/or params.structuredContent
};
app.ontoolresult = (result) => {
// Update UI with final tool result
};
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());
ホストのスタイル設定
テーマを連携させるには、ホストの CSS 変数を使用します。
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
border-radius: var(--border-radius-md);
}
主な変数グループは、--color-background-*、--color-text-*、--color-border-*、--font-sans、--font-mono、--font-text-*-size、--font-heading-*-size、--border-radius-* です。全一覧は src/spec.types.ts を参照してください。
React アプリでは、代わりに useApp と useHostStyles フックを使用します。パターンについては basic-server-react/ を参照してください。
任意の機能強化
App 専用のヘルパーツール
UI から呼び出すものの、モデルが直接呼び出す必要のないツール(ポーリング、ページネーション、チャンクの読み込みなど):
registerAppTool(server, "poll-data", {
description: "Polls 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: "poll-data", arguments: {} })。
CSP の設定
UI で外部リソース(フォント、API、CDN)を読み込む必要がある場合は、ドメインを宣言します。
registerAppResource(server, "My Tool UI", resourceUri, {}, async () => ({
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: {
ui: {
csp: {
connectDomains: ["https://api.example.com"], // fetch/XHR targets
resourceDomains: ["https://cdn.example.com"], // scripts, styles, images
frameDomains: ["https://embed.example.com"], // nested iframes
},
},
},
},
],
}));
部分入力のストリーミング
大きなツール入力の場合、LLM による生成中に進行状況を表示します。
app.ontoolinputpartial = (params) => {
const args = params.arguments; // Healed partial JSON - always valid
// Render preview with partial data
};
app.ontoolinput = (params) => {
// Final complete input - switch to full render
};
getUiCapability() によるグレースフルデグラデーション
クライアントが UI に対応している場合に限り App ツールを登録し、対応していない場合はテキストのみのツールにフォールバックします。
import { getUiCapability, registerAppTool, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
server.server.oninitialized = () => {
const clientCapabilities = server.server.getClientCapabilities();
const uiCap = getUiCapability(clientCapabilities);
if (uiCap?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) {
// Client supports UI — register App tool
registerAppTool(server, "my-tool", {
description: "Shows data with interactive UI",
_meta: { ui: { resourceUri } },
}, appToolHandler);
} else {
// Text-only client — register plain tool
server.registerTool(
"my-tool",
{
description: "Shows data",
inputSchema: z.object({ param: z.string() }),
},
plainToolHandler,
);
}
};
全画面モード
UI を全画面表示に拡張できるようにします。
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;
}
よくある間違いとその回避方法
- テキストの
contentフォールバックを忘れる — UI をサポートしないホスト向けに、テキストを含むcontent配列を必ず用意してください connect()の後にハンドラーを登録する —app.connect()を呼び出す前に、すべてのハンドラーを登録してくださいvite-plugin-singlefileが不足している — これがないと、サンドボックス化された iframe でアセットが読み込まれません- リソースの登録を忘れる — ツールが参照する
resourceUriに対応するリソースが必要です - スタイルをハードコードする — テーマを連携するには、ホストの CSS 変数(
var(--color-*))を使用してください - セーフエリアのインセットを処理しない —
onhostcontextchangedで必ずctx.safeAreaInsetsを適用してください
テスト
basic-host を使用する
basic-host の例を使って、拡張したサーバーをテストします。
# 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
サーバー URL の JSON 配列を使って SERVERS を設定してください(デフォルト:http://localhost:3001/mcp)。
確認項目
- 通常のツールが引き続き動作し、テキスト出力を返す
- アプリツールが iframe 内に UI を描画する
ontoolinputハンドラーがツールの引数で呼び出されるontoolresultハンドラーがツールの結果で呼び出される- ホストのスタイル(テーマ、フォント、色)が正しく適用される