チャット画面内でコンパネUI操作!ConoHa VPS MCP App作ってみた!

こんにちは!GMOインターネット株式会社の河野です。

こちらは、OSS版ConoHa VPS MCP Server に同梱している MCP Apps(AIチャット上で実行するMCPアプリ)の開発記事です。画面上のボタン1つで、オブジェクトストレージ上のファイルをそのまま静的サイトとして公開できる操作画面を、チャットの中に作りました!

「MCPに画面を持たせると何が起きるか」を、実装コードと、埋め込みWebViewで実際に躓いた落とし穴からご紹介していきます。

1. チャットの中に「コンパネ」を出しました

このトグルを1回クリックすると、コンテナに置いたファイルがそのまま静的サイトとして公開され、URLが表示されます。MCP Apps では、このようにチャットUI中に直接操作可能なMCPのUI画面を表示できます。

MCP(Model Context Protocol)は、LLMにテキストでツールを橋渡しするハブとして発展しました。そのハブに画面を持たせるのが MCP Apps です。ツールの結果を、ホスト(Claude Desktop など)の中で直接開くUIとして返せるようになります。

MCP Apps を動かす仕組み自体は驚くほど小さいのですが、その周辺の実装がとても厄介でした。

この記事では、画面層についての話を書きます。

1-1. この記事で扱うこと・扱わないこと

ConoHa VPS MCP そのものについては、すでに2本の記事が出ています。入門・導入手順・実行形態(OSS版/リモート版)・認証まわりはそちらに詳しいので、この記事では扱いません。なお、本記事で紹介するMCP App はMCPサーバー(OSS版)(自分の環境で構築・運用するローカル版。詳しくはこちら)を前提にしています。

知りたいこと読む記事
MCPとは何か/ConoHa VPS MCPで何ができるか/AIエージェントを実際に構築する手順ConoHa VPS MCPで色々なAIエージェントを構築してみた
なぜ作ったのか/開発の意思決定の裏側AIがAIを開発し、AIが利用する。「最速」と「安全」を両立したConoHa VPS MCP開発の裏側
チャットの中に画面をどう出すか/実装の落とし穴この記事

2. なぜチャットの中に画面を持たせるのか

2-1. 「同じ指示の繰り返し」問題

オブジェクトストレージの設定まわりのように、設定する項目が多かったり、対象のコンテナがいくつもあったりすると、同じ形の指示を何度も繰り返すことになります。

・「さっきつくったコンテナをWeb公開して、URLちょうだい」
・「次に作ったコンテナも同じようにWeb公開して、URLちょうだい」
・「最初に公開したほうは、いったん非公開に戻して」
…etc

2-2. 「一覧性の悪さ」問題

LLM は、情報の一覧を表形式で見せてくれることがよくあります。表は、同じ粒度の情報を並べるのには向いています。しかし粒度の違う情報が混ざると、表を分割したり入れ子にしたりする必要が出てきて、だんだん複雑になっていきます。

コンパネであれば、画面を遷移させることで、1画面に載る情報の粒度をそろえられます。

同じ指示を繰り返す手間も、一覧の粒度がばらつく問題も、チャットの中に画面を持たせるとまとめて解決できそうでした。

3. 仕組み:画面が出るまでの3ステップ

なんと画面を出すために必要なのはたったの3つの要素だけです。

1.UIリソースを ui:// スキームで登録する
2.ツール定義に _meta.ui.resourceUri を付けて「このツールの結果はこの画面で開
  く」と宣言する
3.iframe内のAppから app.callServerTool() でホスト経由でサーバーのツールを呼
  び出す

実装には @modelcontextprotocol/ext-apps の registerAppResource / registerAppTool を使っています。

① UIリソースを登録する

ビルド済みの単一HTMLファイルを、ui:// のリソースとして返すだけです。mimeType には MCP Apps 用のプロファイル(RESOURCE_MIME_TYPE、実体は text/html;profile=mcp-app)を指定します。

/** UI リソース URI */
const UI_RESOURCE_URI = "ui://conoha-vps/mcp-app.html";
function registerAppUiResource(server: McpServer): void {
registerAppResource(
server,
UI_RESOURCE_URI,
UI_RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const htmlPath = resolve(
import.meta.dirname,
"ui/src/apps/ui/mcp-app.html",
);
const html = readFileSync(htmlPath, "utf-8");
return {
contents: [
{ uri: UI_RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
}

やっていることは、ファイルを読んで文字列で返すだけです。HTMLの中身をホストがどう扱うかは、サーバー側では意識しません。

② ツールに「結果と表示する画面の紐づけ」を行う

その後、ツール定義に _meta.ui.resourceUri を1つ足すだけで、そのツールの結果が画面として開きます。

registerAppTool(
server,
"list_containers",
{
title: "ストレージコンテナ一覧取得",
description:
"オブジェクトストレージのコンテナ一覧(名前・オブジェクト数・サイズ)を取得します。",
inputSchema: {},
outputSchema: {
containers: z.array(AppContainerSchema),
total: z.number(),
},
// ここが「このツールの結果はこの画面で開く」の宣言
_meta: {
ui: {
resourceUri: UI_RESOURCE_URI,
},
},
},
async () => {
const containers = await resolveContainers();
const output = { containers, total: containers.length };
return {
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
structuredContent: output,
};
},
);

ポイントは、content(テキスト)と structuredContent の両方を返している点です。画面が出ないホストではテキストとして読めて、出るホストでは画面になる。UIは追加のレイヤーであって、テキストの代替ではありません。そのため、ここを崩すと、非対応ホストで何も返らないツールになってしまいます。

③ 画面からサーバーのツールを呼び出す

iframe内のReactアプリからは、app.callServerTool() でホスト経由でサーバーのツールを呼びます。App インスタンスはシングルトンで1つ持ち、9種類のツール呼び出しを型付きの関数にまとめました。

const app = new App({ name: "ConoHa VPS Storage", version: "0.2.0" });
const connected = app.connect();
export async function fetchContainers(): Promise<AppContainer[]> {
try {
const result = await app.callServerTool({
name: "list_containers",
arguments: {},
});
const text = extractText(result);
if (text) {
const parsed = JSON.parse(text) as { containers?: AppContainer[] };
return parsed.containers ?? [];
}
} catch {
/* ignore — UI 側でロード失敗時の空表示を行う */
}
return [];
}

なんとこれだけで画面は表示されます!(躓きやすいのはここから先の実装です)

4. 実装:画面を「単一HTML」に畳んで配る

UIは React 19 + Vite で書いて、vite-plugin-singlefile でCSS・JS・SVGを1枚のHTMLに固めています。

export default defineConfig({
// svgr は `?react` 付き import を React コンポーネントに変換する。
// `?react` が無い SVG import は従来どおり URL を返す(top-bar のロゴ等)。
plugins: [svgr(), react(), viteSingleFile()],
build: {
outDir: "dist/ui",
rollupOptions: {
input: {
"mcp-app": resolve(__dirname, "src/apps/ui/mcp-app.html"),
},
},
},
});

なぜ単一ファイルなのか。理由は3つあります。

・配布単位が1つで済む:resources/read で返すのは1つのテキストです。JSやCSSが
 別ファイルだと、それぞれをリソース登録して相対パスの解決をホスト任せにするこ
 とになります
・ホストの読み込み単位に合う:ホストはHTMLをiframeに流し込みます。外部参照が
 無ければ、ネットワークアクセスもオリジンの心配も発生しません
・デバッグが単純になる:思いがけぬ副作用として、表示されない時に「JSだけ読み込
 めない」等の中間状態が無くなるため、デバッグ時にありがたい性質でしたバッグが
 単純になる:思いがけぬ副作用として、表示されない時に「JSだけ読み込めない」等
 の中間状態が無くなるため、デバッグ時にありがたい性質でした

5. ホストに溶け込ませる ― テーマ連携

画面が出た直後の感想は「貼り付けた感がすごい」でした。ホストがダークテーマなのに、iframeの中だけ真っ白なのです。

getHostContext() でホストのライト/ダークを受け取り、onhostcontextchanged で切り替えに追従します。テーマを渡してくれないホストでは prefers-color-scheme にフォールバックします。

function applyHostContext(): void {
const ctx = app.getHostContext();
if (ctx?.theme) {
applyDocumentTheme(ctx.theme);
} else {
// テーマを渡さないホストでは OS 設定にフォールバック
const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
applyDocumentTheme(dark ? "dark" : "light");
}
}
export function initHostTheming(): () => void {
applyHostContext(); // 1. 即時に一度適用
void connected.then(applyHostContext); // 2. connect 完了後に再適用
app.onhostcontextchanged = () => applyHostContext(); // 3. 切替に追従
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const onOsThemeChange = () => {
// 4. ホストがテーマ非対応のときのみ OS テーマ変更にも追従
if (!app.getHostContext()?.theme) applyHostContext();
};
mql.addEventListener("change", onOsThemeChange);
return () => mql.removeEventListener("change", onOsThemeChange);
}

しかし、ここまでやっても、まだ「貼り付けた感」は消えませんでした。最終的に効いたのは、iframeの外枠の地色をホストの背景色に合わせることでした。これでClaudeのチャット内にうまく溶け込ませることができました。

ホストの色をハードコードするのは、本来避けたいところです。ただ、外枠の地色を受け取る標準的な手段が見当たらなかったので、今のところはこの方法に落ち着いています。

6. やってみて躓いた落とし穴

6-1. クリップボードAPIが使えない

公開URLを表示したら、当然コピーボタンが欲しくなります。最初、navigator.clipboard.writeText() で実装したのですが、これが動いてくれませんでした。

MCP App は埋め込みWebView(iframe)の上で動くため、navigator.clipboard が使えないことがあります。私が確認できた範囲では、非セキュアコンテキスト扱いになっているか、Permissions Policy で clipboard-write が許可されていないかのどちらかでした。しかもこれはホストの実装次第で変わるので、「自分の環境では動いた」が通用しません。

対処は、document.execCommand(‘copy’) へのフォールバックです。これはユーザージェスチャー起点であればiframe内でも動きます。

export async function copyToClipboard(text: string): Promise<boolean> {
// 型上は常に存在するが、実行環境では undefined になりうるため明示的に絞り込む
const clipboard = navigator.clipboard as Clipboard | undefined;
if (clipboard) {
try {
await clipboard.writeText(text);
return true;
} catch {
// Permissions Policy 等で拒否された場合は execCommand にフォールバック
}
}
return copyViaExecCommand(text);
}

型定義上 navigator.clipboard は常に存在する扱いなので、TypeScriptは「使える」と信じています。実行環境では undefined になりうるため、明示的なキャストで絞り込みました。型が通っても動くとは限らない、という当たり前のことを、埋め込みWebViewは何度も思い出させてくれます。

6-2. アップロードをBase64のみに限定した(代償は10MB上限)

これは落とし穴ではなく、意図した設計判断です。ただ、代償が分かりやすい形で出たので載せておきます。

UIからファイルをアップロードする方法は2つ考えられました。

1.UIがファイルパスを渡し、サーバー側が readFile する
2.UIがブラウザの File API で読み出し、Base64にしてサーバーに渡す

1を選ぶと、UIからサーバーに任意のファイルパスを読ませられることになります。UIはiframeの中で動く、比較的信頼度の低い側です。ここからサーバーのファイルシステムに手が届く経路を作りたくなかったので、2を選びました。信頼境界をまたぐのはバイト列だけ、という形です。

その結果、ファイル本体がMCPメッセージのJSONに乗ることになり、おおむね10MBという上限が生まれました。UI側でも送信前にファイルサイズを見て弾き、ツールのdescriptionにも明記しています。

content_base64: z
.string()
.min(1)
.describe("Base64 エンコードされたオブジェクト本体"),

大きいファイルを扱いたい人には不便な仕様です。とはいえ、これは「機能が足りない」のではなく「安全側に振った結果」なので、上限を上げるのではなく、別の経路(署名付きURLなど)を用意するのが筋だと考えています。現時点では、10MBを超えるファイルは汎用のAPIツール経由で扱ってもらうようご案内しています。

6-3. 配布物からUIのHTMLだけが消えた

一番ひやりとしたのがこれです。

ツールは動くしコンテナ一覧もちゃんと返るのに、なぜか画面だけ出ない。

原因は .mcpbignore でした。ソースコードを配布物から除外するために src/ と書いていたのですが、これは gitignore セマンティクスで解釈されるため、パスの途中の src にもマッチします。その結果、ビルド済みUIの置き場所である dist/ui/src/apps/ui/mcp-app.html も除外対象になっていました。

UIリソースは readFileSync でHTMLを読みます。ファイルが無いので、リソースの取得だけが失敗し、ツール本体は正常に動く。それが「ツールは動くのに画面が出ない」という症状の正体でした。

対処は、先頭にスラッシュを付けて配布物のルートに固定するだけでした。今は理由をコメントに残しています。

# Source and test code
# NOTE: leading slash anchors to bundle root. Without it, `src/` (gitignore
# semantics) also matches `dist/ui/src/...`, which would strip the bundled MCP
# App UI HTML (dist/ui/src/apps/ui/mcp-app.html) and break the storage UI in
# Claude Desktop. Keep these anchored.
/src/
/test/

学んだのは、配布形態ごとに事故の出方が違うということでした。npm版は必要なファイルを列挙するallowlist方式なので無傷で、壊れたのは .mcpb(Claude Desktop用のバンドル)だけです。同じソースから作った2つの配布物のうち片方だけが壊れる、というのは、テストで捕まえにくい種類の不具合です。

6-4. UIを開くツールが1つも無くなった

リファクタリングの途中で、_meta.ui の紐付けが消えました。registerAppTool は使われているのに、resourceUri の宣言だけが落ちた状態です。

結果は前項と同じ「画面が出ない」ですが、原因は正反対です。前項はファイルが無い。こちらはファイルはあるのに、それを開くきっかけを持つツールが1つも無い。この画面の入口は list_containers 1つだけなので、そこから宣言が落ちれば、画面へ入る経路がまるごと消えます。

MCP Apps では、UIリソースの登録とツールへの紐付けが別々の場所に書かれます。片方だけ生き残っても型エラーになりませんし、テストも通ります。「画面が出るか」を検証する経路が無かったのが根本原因でした。

URIは定数1つに集約してあるので紐付けの場所は追えます。現時点では、画面が実際に開くことをホスト上で確認しています。今後は、この確認の自動化も検討しています。

6-5. GUI経由でも入力検証はサーバー側に必要

コンテナ名には命名規則があります。UI側でバリデーションを書いたので、サーバー側は素通しでもいいかと一瞬考えました。しかし、それは避けるべきだと考え直しました。UIは信頼できないクライアントで、同じツールはAIからも直接呼ばれます。

とはいえ、UI側とサーバー側で別々に正規表現を書くと、どちらかがズレやすくなります。そこで規則を container-name-rules.ts に切り出し、単一の真実源にして両側から参照しています。

inputSchema: {
// 命名規則は container-name-rules.ts を単一の真実源とし、
// UI 側 validate-container-name.ts と規則を共有する
name: z
.string()
.min(CONTAINER_NAME_MIN_LENGTH)
.max(CONTAINER_NAME_MAX_LENGTH)
.regex(
CONTAINER_NAME_PATTERN,
"コンテナ名は英数字とハイフン・アンダースコア・ピリオドのみ使用できます",
)
.describe("作成するコンテナ名"),

UI側の即時フィードバックは体験のため、サーバー側の検証は安全のため。役割が違うので、両方書いておくのが適切だと考えています。共有するのは規則だけです。テストでも、UIと同じ規則でサーバー側が弾くことを確認しています。

6-6. UIを壊さないエラー設計に

最後は地味ですが、効いた方針です。ホストに例外を投げないことです。

iframe内のUIで例外が飛ぶと、画面が真っ白になったり、ホスト側にエラーが出たりします。ユーザーから見れば「AIが壊れた」ようにしか見えません。そこで、参照系と変更系で別の方針を採りました。

・参照系(一覧取得など)は、失敗時に空配列へフォールバックする。UIは「コンテナ
 がありません」を表示する。画面は生きている
・変更系(作成・削除・公開切替)は、成功/失敗を結果型で返し、UI側で ok を見て
 分岐する。例外は境界の内側で吸収する

type MutationResult =
| { ok: true; publicUrl?: string }
| { ok: false; error: string; hint?: string };

hint フィールドが地味に便利でした。たとえばコンテナ削除は、中身が空でないと409で失敗します。そのとき「先にオブジェクトを全て削除してください」というヒントをサーバー側から返し、UIでそのまま表示しています。エラーメッセージの文言をUI側に散らさずに済みます。

7. 実は一度、作ったダッシュボードを捨てました

ここからは開発の裏話です。

当初は複数機能を備えたダッシュボードとして開発していましたが、まずは「チャットの中で操作できる画面」の体験を磨くため、ストレージ機能に絞ってリリースしました。

多機能で60点の画面と、1機能で90点の画面なら、後者の方がMCP Appsという新しい層について学べることが多いです。実際、この記事に書いた落とし穴のほとんどは、機能を削ってから細部を詰める段階で見つかりました。広く作っていた頃には気づいていませんでした。

削ったあと、残った1機能をさらに作り直しました。

・素のHTML/JS → React 19 へ:デザイナーと組み、デザインどおりの見た目に移植
 しました
・サーバー側のモックモードを撤去:開発中はサーバーにモック応答を仕込んでUIを確
 認していました。これを削除し、開発専用のViteプレビューに置き換えました

2つ目は、方針として書いておきたい点です。モックを製品コードに持ち込まない。サーバー側にモックモードがあると、それは製品に含まれる分岐になります。意図しない環境でモック応答が有効になる可能性をなくし、テスト対象を明確にするため、モック機能は開発用のViteプレビューへ分離しました。UIの見た目を確認するのは開発ツール側の仕事です。今は src/apps/ui/preview/ にモックブリッジを置き、Viteのプレビューでのみ使っています。配布物には含まれません。

8. 小さい画面ほど効いたUXの工夫

チャットの中に置ける画面は、そう大きくはありません。その制約の中で、入れてよかったと思っている点を挙げたいと思います。

・公開/非公開はトグル1個に統一:最初は「公開する」「非公開にする」の2ボタンで
 した。状態と操作が分離していると、今どちらなのかを読み取る手間が増えます。ト
 グルなら状態がそのまま操作になります
・破壊的操作には確認ダイアログ:コンテナ削除・オブジェクト削除は確認をはさみま
 す。チャットの流れの中にある画面は指が軽くなりがちなので、通常の管理画面より
 むしろ必要でした
・MIMEタイプをバッジで見せる:オブジェクト一覧で text/html なのか
 application/octet-stream なのかは、静的サイト公開の文脈では重要な情報です。
 文字列を並べるだけだと視線が滑るので、バッジにして拾いやすくしました
・認証情報が未設定なら、エラーではなくセットアップ手順を出す:初回に空の一覧や
 エラーが表示されると、使い始めの印象を損ねてしまいます。何をすればこの画面が
 使えるようになるかを、画面の中で案内します
・公開URLバーとコピーボタン:公開したら、次にやりたいのはURLを開くか誰かに送
 ることです。URLを表示して終わりにせず、コピーまでを1クリックで終わらせます
 (そのために 6-1 のフォールバックが必要になったわけです)

どれも派手ではありませんが、「チャットの中の小さな管理コンソール」が成立するかどうかは、この積み上げで決まったと思っています。

9. 正直な現在地 ― ベータ版として、まだまだな点

・コンテナ直アクセスで index.html が自動表示されない:静的サイトホスティング
 として使うなら当然期待される挙動ですが、現在はファイル名まで指定する必要が
 あります
・UIからのアップロードは10MBまで:設計判断の代償なのでしかたがないのです
 が、今後、リモート版MCPなどで大容量ファイルに対応できると、より使いやすく
 なりそうです。
・そもそもMCP Apps対応ホストでしか画面が出ない:これが一番大きい制約です。非
 対応のホストではテキストが返るだけになります。content と structuredContent
 の両方を返しているのは、このためです

MCP Apps はまだ新しい層で、対応状況はホスト側の実装に依存します。「MCP サーバーが対応していれば必ず画面が出る」わけではない、という前提は、実装する側も使う側も持っておいたほうがよい段階だと思います。

10. まとめ

ツールの結果に画面を持たせてみて、変わったことを2つ挙げたいと思います。

1.「対象を指す」コストがゼロになった:同じ形の指示を対象ごとに言い直す往復が
  消えました。曖昧さの解消はGUIの方が速い、という当たり前の事実が、AIチャッ
  トの中でもそのまま成り立ちます
2.実装は小さく、周辺は厄介:UIリソース1つとツール定義への1行で画面は出ま
  す。そこから先の、クリップボード・配布物・テーマ・エラー設計・信頼境界の方
  が、はるかに時間を使いました

ConoHa VPS MCP Server は Apache-2.0 で OSS 公開しています(現在ベータ版です)。
この記事で紹介したMCP Apps UIのコードも、すべて読める状態にあります。「ここはこう書いた方がいい」「このホストでは動かなかった」といったご指摘は、GitHubのIssueで歓迎です。
スターもぜひお願いします。

ブログの著者欄

河野 稜介

システム本部

プロフィール情報:入社以来、お名前・とくとくBBにおけるAI関連の開発・運用に従事。RAG開発やモデルチューニングを複数手がけるほか、社内開発におけるデータ分析に従事。トークン効率化やモデル蒸留、分散型コンピューティングに特に関心を持つ。

採用情報

関連記事

KEYWORD

TAG

もっとタグを見る

採用情報

SNS FOLLOW

GMOインターネットグループのSNSをフォローして最新情報をチェック