build-mcp-app について
インタラクティブな UI ウィジェットを備えた MCP サーバーを構築する開発者向けスキルです。登録、iframe 通信、デプロイ、テストを扱い、テキストや elicitation で十分な場面も示します。
主な機能
- 01ツールと UI リソースの登録
- 02iframe ベースのウィジェット構築
- 03ウィジェット、ホスト、サーバーツールの接続
- 04リモートまたは MCPB のデプロイ選択
- 05Claude とヘッドレス JSON-RPC でのテスト
活用シーン
- 01大規模リスト向けの検索可能な選択 UI
- 02チャート、地図、差分、プレビューの表示
- 03ウィジェットによる構造化入力の収集
- 04長時間ジョブの進捗表示
MCP 対応
互換性のあるホストにツールと UI リソースを提供する MCP サーバーの構築を案内します。ウィジェットはホストと通信し、別のサーバーツールも呼び出せます。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ anthropics/ claude-plugins-official --skill build-mcp-appインストール後、Claude Code で/build-mcp-appを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/abuse-protection.mdapps-sdk-messages.mddirectory-checklist.mdiframe-sandbox.mdpayload-budgeting.mdwidget-templates.md詳細
MCP アプリの構築(インタラクティブ UI ウィジェット)
MCP アプリは、UI リソースも提供する標準的な MCP サーバーです。チャット画面内にインラインで表示されるインタラクティブなコンポーネントを提供します。一度構築すれば、Claude、ChatGPT、そして apps サーフェスを実装するほかのホストでも動作します。
UI レイヤーは追加機能です。内部では、引き続きツールとリソースを使い、同じワイヤープロトコルを使用します。通常の MCP サーバーをまだ構築したことがない場合は、build-mcp-server スキルで基本レイヤーを確認できます。このスキルでは、その上にウィジェットを追加します。
Claude でのテスト: claude.ai にカスタムコネクターとしてサーバーを追加します(ローカル開発では Cloudflare トンネル経由)。これにより、実際の iframe サンドボックスと
hostContextをテストできます。https://claude.com/docs/connectors/building/testing を参照してください。
Claude ホスト固有の仕様
_meta.ui.* キー | 場所 | 効果 |
|---|---|---|
resourceUri | ツール | このツールの結果を表示するためにホストがレンダリングする ui:// リソースを指定します。 |
visibility: ["app"] | ツール | ウィジェット専用の補助ツール(例:callServerTool 経由で呼び出されるジオメトリ/画像フェッチャー)を Claude のツール一覧から非表示にします。 |
prefersBorder: false | リソース | ホストの外側のカード枠を非表示にします(モバイル)。 |
csp.{connectDomains, resourceDomains, baseUriDomains} | リソース | 外部オリジンを宣言します。デフォルトではすべてブロックされます。frameDomains は現在 Claude では制限されています。 |
hostContext.safeAreaInsets: {top, right, bottom, left}(px):ノッチとコンポーザーのオーバーレイを考慮してください。- ディレクトリーへの登録には、OAuth または認証なし(
none)が必要です。静的ベアラー認証はプライベートデプロイ専用で、一覧への登録を妨げます。また、ツールのannotationsと PNG スクリーンショット 3~5 枚も必要です。references/directory-checklist.mdを参照してください。
ウィジェットが通常のテキストより適している場合
UI を追加すること自体を目的にしないでください。ほとんどのツールはテキストまたは JSON を返せば十分です。次のいずれかに当てはまる場合は、ウィジェットを追加してください。
| 判断材料 | ウィジェットの種類 |
|---|---|
| Claude が確実に推測できない構造化入力をツールが必要とする | フォーム |
| Claude が順位付けできない一覧(ファイル、連絡先、レコード)からユーザーが選択する必要がある | ピッカー/テーブル |
| 破壊的な操作や課金対象の操作に明示的な確認が必要 | 確認ダイアログ |
| 出力が空間的または視覚的(グラフ、地図、差分、プレビュー) | 表示ウィジェット |
| ユーザーが状況を確認したい長時間実行のジョブ | 進捗/ライブステータス |
いずれにも当てはまらない場合は、ウィジェットを使わないでください。テキストのほうが、構築もユーザーの利用も速くなります。
ウィジェットとエリシテーション — 適切に使い分ける
ウィジェットを構築する前に、エリシテーションで対応できるか確認してください。エリシテーションは仕様にネイティブに組み込まれており、UI コードは不要で、準拠するすべてのホストで動作します。
| 必要なこと | エリシテーション | ウィジェット |
|---|---|---|
| はい/いいえの確認 | ✅ | 過剰 |
| 短い列挙型から選択 | ✅ | 過剰 |
| 単純なフォームへの入力(名前、メールアドレス、日付) | ✅ | 過剰 |
| 大規模または検索可能な一覧から選択 | ❌(スクロール/検索不可) | ✅ |
| 選択前のビジュアルプレビュー | ❌ | ✅ |
| グラフ/地図/差分の表示 | ❌ | ✅ |
| リアルタイム更新の進捗 | ❌ | ✅ |
エリシテーションで対応できる場合は、それを使ってください。../build-mcp-server/references/elicitation.md を参照してください。
アーキテクチャ:2 つのデプロイ形態
リモート MCP アプリ(最も一般的)
ホスト型のストリーム可能な HTTP サーバーです。ウィジェットのテンプレートはリソースとして提供され、ツールの結果から参照されます。ホストはリソースを取得して iframe サンドボックス内にレンダリングし、ウィジェットと Claude の間のメッセージを仲介します。
┌──────────┐ tools/call ┌────────────┐
│ Claude │─────────────> │ MCP server │
│ host │<── result ────│ (remote) │
│ │ + widget ref │ │
│ │ │ │
│ │ resources/read│ │
│ │─────────────> │ widget │
│ ┌──────┐ │<── template ──│ HTML/JS │
│ │iframe│ │ └────────────┘
│ │widget│ │
│ └──────┘ │
└──────────┘
MCPB パッケージ版 MCP アプリ(ローカル+UI)
同じウィジェットの仕組みですが、サーバーは MCPB バンドル内でローカルに実行されます。実際のローカルディスクを参照するファイルピッカーや、デスクトップアプリを操作するダイアログなど、ウィジェットからローカルアプリケーションを操作する必要がある場合に使用します。
MCPB のパッケージ化の仕組みについては、build-mcpb スキルを参照してください。以下の内容は、どちらの形態にも適用されます。
ウィジェットとツールの紐付け
ウィジェット対応ツールには、個別に登録する項目が2つあります。
- ツールは、
_meta.ui.resourceUriを使って UI リソースを宣言します。ハンドラーが返すのはプレーンテキスト/JSON であり、HTML ではありません。 - リソースは別途登録し、HTML を提供します。
Claude がツールを呼び出すと、ホストは _meta.ui.resourceUri を確認してそのリソースを取得し、iframe に描画します。その後、ツールの戻り値を ontoolresult イベント経由で iframe に渡します。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
const server = new McpServer({ name: "contacts", version: "1.0.0" });
// 1. The tool — returns DATA, declares which UI to show
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker",
annotations: { title: "Pick Contact", readOnlyHint: true },
inputSchema: { filter: z.string().optional() },
_meta: { ui: { resourceUri: "ui://widgets/contact-picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter);
// Plain JSON — the widget receives this via ontoolresult
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
// 2. The resource — serves the HTML
registerAppResource(
server,
"Contact Picker",
"ui://widgets/contact-picker.html",
{},
async () => ({
contents: [{
uri: "ui://widgets/contact-picker.html",
mimeType: RESOURCE_MIME_TYPE,
text: pickerHtml, // your HTML string
}],
}),
);
ui:// URI スキームは慣例です。MIME タイプは必ず RESOURCE_MIME_TYPE("text/html;profile=mcp-app")にしてください。これによりホストは、ソースを表示するだけでなく、インタラクティブな iframe として描画することを認識します。
ウィジェットのランタイム — App クラス
iframe 内では、スクリプトから @modelcontextprotocol/ext-apps の App クラスを使ってホストと通信します。これは双方向の永続的な接続です。会話がアクティブである限りウィジェットは動作し続け、新しいツールの結果を受け取り、ユーザーの操作を送信します。
<script type="module">
/* ext-apps bundle inlined at build time → globalThis.ExtApps */
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
// Set handlers BEFORE connecting
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
render(contacts);
};
await app.connect();
// Later, when the user clicks something:
function onPick(contact) {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${contact.id}` }],
});
}
</script>
/*__EXT_APPS_BUNDLE__*/ プレースホルダーは、起動時にサーバーによって @modelcontextprotocol/ext-apps/app-with-deps の内容に置き換えられます。これが必要な理由と書き換えスニペットについては、references/iframe-sandbox.md を参照してください。import { App } from "https://esm.sh/..." は使用しないでください。iframe の CSP が依存パッケージの取得をブロックするため、ウィジェットが空白で表示されます。
| メソッド | 方向 | 用途 |
|---|---|---|
app.ontoolresult = fn | ホスト → ウィジェット | ツールの戻り値を受け取る |
app.ontoolinput = fn | ホスト → ウィジェット | ツールの入力引数(Claude が渡した値)を受け取る |
app.sendMessage({...}) | ウィジェット → ホスト | 会話にメッセージを挿入する |
app.updateModelContext({...}) | ウィジェット → ホスト | コンテキストを通知せずに更新する(表示されるメッセージなし) |
app.callServerTool({name, arguments}) | ウィジェット → サーバー | サーバー上の別のツールを呼び出す |
app.openLink({url}) | ウィジェット → ホスト | URL を新しいタブで開く(サンドボックスが window.open をブロックします) |
app.getHostContext() / app.onhostcontextchanged | ホスト → ウィジェット | テーマ、ホストの CSS 変数、containerDimensions、displayMode、deviceCapabilities |
app.requestDisplayMode({mode}) | ウィジェット → ホスト | inline/pip/fullscreen を要求する |
app.downloadFile({name, mimeType, content}) | ウィジェット → ホスト | ホスト経由でダウンロードする(base64 コンテンツ) |
new App(info, caps, {autoResize: true}) | — | iframe の高さを描画されたコンテンツに合わせる |
sendMessage は、「ユーザーが何かを選んだので Claude に伝える」という一般的な方法です。updateModelContext は、Claude に知らせる必要はあるものの、チャットを煩雑にしたくない状態に使用します。外部への移動にはすべて openLink が必須です。サンドボックス属性により、window.open と <a target="_blank"> はブロックされます。
ウィジェットでできないこと:
- ホストページの DOM、Cookie、ストレージへのアクセス
- 任意のオリジンへのネットワーク呼び出し(CSP による制限。
callServerTool経由で行ってください) - ポップアップを開くこと、または直接移動すること。
app.openLink({url})を使用してください - リモート画像の安定した読み込み。サーバー側で
data:URL として埋め込んでください
ウィジェットは小さく、単一の目的に絞ってください。ピッカーは選択を行い、チャートは表示を行います。iframe 内にサブアプリ全体を構築するのではなく、用途を絞った複数のツールに分割してください。
スキャフォールド:最小限のピッカーウィジェット
インストール:
npm install @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod express
サーバー(src/server.ts):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerAppTool, registerAppResource, RESOURCE_MIME_TYPE }
from "@modelcontextprotocol/ext-apps/server";
import express from "express";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { z } from "zod";
const require = createRequire(import.meta.url);
const server = new McpServer({ name: "contact-picker", version: "1.0.0" });
// Inline the ext-apps browser bundle into the widget HTML.
// The iframe CSP blocks CDN script fetches — bundling is mandatory.
const bundle = readFileSync(
require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8",
).replace(/export\{([^}]+)\};?\s*$/, (_, body) =>
"globalThis.ExtApps={" +
body.split(",").map((p) => {
const [local, exported] = p.split(" as ").map((s) => s.trim());
return `${exported ?? local}:${local}`;
}).join(",") + "};",
);
const pickerHtml = readFileSync("./widgets/picker.html", "utf8")
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
registerAppTool(server, "pick_contact", {
description: "Open an interactive contact picker. User selects one contact.",
annotations: { title: "Pick Contact", readOnlyHint: true },
inputSchema: { filter: z.string().optional().describe("Name/email prefix filter") },
_meta: { ui: { resourceUri: "ui://widgets/picker.html" } },
}, async ({ filter }) => {
const contacts = await db.contacts.search(filter ?? "");
return { content: [{ type: "text", text: JSON.stringify(contacts) }] };
});
registerAppResource(server, "Contact Picker", "ui://widgets/picker.html", {},
async () => ({
contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }],
}),
);
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(process.env.PORT ?? 3000);
デスクトップアプリの操作やローカルファイルの読み取りなど、ローカル専用のウィジェットアプリでは、トランスポートを StdioServerTransport に切り替え、build-mcpb スキルを使ってパッケージ化してください。
ウィジェット(widgets/picker.html):
<!doctype html>
<meta charset="utf-8" />
<style>
body { font: 14px system-ui; margin: 0; }
ul { list-style: none; padding: 0; margin: 0; max-height: 300px; overflow-y: auto; }
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
li:hover { background: #f5f5f5; }
.sub { color: #666; font-size: 12px; }
</style>
<ul id="list"></ul>
<script type="module">
/*__EXT_APPS_BUNDLE__*/
const { App } = globalThis.ExtApps;
(async () => {
const app = new App({ name: "ContactPicker", version: "1.0.0" }, {});
const ul = document.getElementById("list");
app.ontoolresult = ({ content }) => {
const contacts = JSON.parse(content[0].text);
ul.innerHTML = "";
for (const c of contacts) {
const li = document.createElement("li");
li.innerHTML = `<div>${c.name}</div><div class="sub">${c.email}</div>`;
li.addEventListener("click", () => {
app.sendMessage({
role: "user",
content: [{ type: "text", text: `Selected contact: ${c.id} (${c.name})` }],
});
});
ul.append(li);
}
};
await app.connect();
})();
</script>
その他のウィジェットの形については、references/widget-templates.md を参照してください。
書き直しを防ぐ設計上のポイント
ツールごとにウィジェットを1つにします。 何でもできる巨大なウィジェットを作りたくなる気持ちを抑えましょう。1つのツール → 焦点を絞った1つのウィジェット → 明確な結果の形、という構成にします。Claude はこの構成のほうがはるかに適切に推論できます。
ツールの説明にウィジェットについて記載します。 Claude が呼び出すツールを決める際に参照するのは、ツールの説明だけです。説明に「インタラクティブな選択画面を開きます」と記載することで、Claude は ID を推測するのではなく、そのツールを選ぶようになります。
実行時にウィジェットは必須ではありません。 apps サーフェスに対応していないホストは、単に _meta.ui を無視し、ツールのテキストコンテンツを通常どおり表示します。ツールハンドラーはすでに意味のあるテキスト/JSON(ウィジェットのデータ)を返しているため、自動的にフォールバックします。Claude はウィジェット経由ではなく、データを直接確認できます。
読み取り専用ツールでは、ウィジェットの結果を待たせないようにします。 データを単に 表示する ウィジェット(グラフ、プレビューなど)で、完了にユーザーの操作を必要とするべきではありません。Claude が待たずに推論を続けられるよう、表示用ウィジェットとテキストの要約を同じ結果で返します。
ツールの数ではなく、項目数でレイアウトを切り替えます。 1つのユースケースが「1件の結果を詳しく表示する」、別のユースケースが「複数の結果を横並びで表示する」場合でも、ツールを2つに分けないでください。items[] を受け取る1つのツールを作り、ウィジェット側でレイアウトを選択します。items.length === 1 → 詳細表示、> 1 → カルーセル、という形です。これによりサーバーのスキーマをシンプルに保ち、件数を自然に Claude に判断させられます。
Claude の推論をペイロードに含めます。 各項目に短い note フィールド(Claude がそれを選んだ理由)を設け、カード上のコールアウトとして表示すると、選択肢とともに推論をユーザーに示せます。Claude がこのフィールドを設定するよう、ツールの説明にも記載してください。
サーバー側で画像の形状を正規化します。 データソースから縦横比が大きく異なる画像が返される場合、データ URL としてインライン取得する 前に、予測可能なバリエーション(例:正方形の範囲内に収める形)に変換します。そのうえで、ウィジェットの画像コンテナーに固定の aspect-ratio と object-fit: contain を設定し、すべての画像が中央に収まるようにします。
ホストのテーマに合わせます。 connect() の後に app.getHostContext()?.theme を使い、ライブ更新には app.onhostcontextchanged を使用します。<html> に .dark クラスを切り替えて、色は CSS カスタムプロパティで管理し、:root.dark {} のオーバーライドブロックを用意して、color-scheme を設定します。ダークモードでは mix-blend-mode: multiply を無効にしてください。画像が消えてしまいます。
テスト
Claude Desktop — 現行ビルドでは、まだ command/args の設定形式が必要です(ネイティブの "type": "http" は使えません)。mcp-remote でラップし、http-only トランスポートを強制して、SSE プローブによってウィジェット機能のネゴシエーションが妨げられないようにします。
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://localhost:3000/mcp",
"--allow-http", "--transport", "http-only"]
}
}
}
Desktop は UI リソースを積極的にキャッシュします。ウィジェットの HTML を編集した後は、リソースを確実に再取得させるため、完全に終了(ウィンドウを閉じるのではなく、⌘Q/Alt+F4)してから再起動してください。
ヘッドレス JSON-RPC ループ — Desktop 上で操作しなくても、すばやく反復できます。
# test.jsonl — one JSON-RPC message per line
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"your_tool","arguments":{...}}}
(cat test.jsonl; sleep 10) | npx mcp-remote http://localhost:3000/mcp --allow-http
sleep によって、すべての応答を収集できる時間だけ stdin を開いたままにします。jq または Python のワンライナーで jsonl 出力を解析します。
ウィジェットの開発ループ — インライン化されたウィジェット HTML を、偽の ExtApps シムを用いて通常の GET ルートで提供します。このシムはクエリパラメーターから ontoolresult を発火するため、⌘Q による再起動の繰り返しを避けられます。
app.get("/widget-preview", (_req, res) => {
const shim = `globalThis.ExtApps={applyHostStyleVariables:()=>{},App:class{
constructor(){this.h={}} ontoolresult;onhostcontextchanged;
async connect(){const p=new URLSearchParams(location.search).get("payload");
if(p)this.ontoolresult?.({content:[{type:"text",text:p}]});}
getHostContext(){return{theme:"light"}}
sendMessage(m){console.log("sendMessage",m)} updateModelContext(){}
callServerTool(){return Promise.resolve({content:[]})} openLink(){} downloadFile(){}
}};`;
res.type("html").send(widgetHtml.replace("/*__EXT_APPS_BUNDLE__*/", shim));
});
通常のブラウザータブで http://localhost:3000/widget-preview?payload={"rows":[...]} を開き、通常の開発者ツールを使って反復作業を行います。
ホストのフォールバック — apps サーフェスに対応していないホスト(または MCP Inspector)を使い、ツールのテキストコンテンツが適切にフォールバックすることを確認します。
CSP のデバッグ — iframe 自身の開発者ツールのコンソールを開きます。CSP 違反は、ウィジェットが何も表示されずに失敗する(空白の領域が表示され、メインのコンソールにはエラーが出ない)最大の原因です。references/iframe-sandbox.md を参照してください。
参考ファイル
references/iframe-sandbox.md— CSP/サンドボックスの制約、バンドルをインライン化するパターン、画像の処理、ホストのテーマ設定references/widget-templates.md— ピッカー/確認/進行状況/表示用の再利用可能な HTML スキャフォールドreferences/apps-sdk-messages.md—Appクラスの API:ウィジェット ↔ ホスト ↔ サーバーのメッセージング、ライフサイクル、置き換え処理references/payload-budgeting.md— ホストのツール結果サイズ上限、不要データの削除後に切り詰める手順、callServerToolを使用した大容量アセットの処理references/abuse-protection.md— Anthropic の送信 CIDR、段階的なレート制限、trust proxy、レスポンスのキャッシュreferences/directory-checklist.md— コネクターディレクトリへの登録申請前チェックリスト