💻

MCP Apps を実装してわかったこと ─ UIをチャットに埋めるまでの試行錯誤

に公開

本記事は、MCP Apps を 実際に Python で実装しながら試行錯誤した体験の記録 です。
公式ドキュメントや既存の解説記事の要約ではなく、「なぜ動かなかったのか」「どこで詰まったのか」「実装して何が見えたのか」 にフォーカスしています。

MCP Apps / UI Resource を初めて触る人を主な対象としています。「MCP Apps をこれから触る人が、同じ落とし穴に落ちないための記録」として参考にしてください。
MCP の基本概念(Tool / Resource / Host / Server)については、最低限の用語補足を交えながら進めます。


0. はじめに

MCP(Model Context Protocol)および MCP Apps は、 Anthropic によって提唱・公開されているオープンな仕様です。公式情報としては、以下が一次情報になります。

情報 URL
MCP 公式サイト(仕様・思想) https://modelcontextprotocol.io/
MCP GitHub(仕様・SDK・examples) https://github.com/modelcontextprotocol
Anthropic による MCP 発表ブログ https://www.anthropic.com/news/model-context-protocol

仕様については上記を読めば概念は理解できると思います。
また、日本語でも MCP / MCP Apps を解説する記事が少しずつ増えています。

一方で、

  • UI Resource は仕様を読んでも挙動までイメージできない
  • 実装時にハマるポイントはどこだろう

これは、読んでもわからないやつだな
そう感じて、実装して確かめることにしました。


1. MCP Appをpythonで作ってみる

この章では、「UI Resource が表示される最小構成」を作ります。
動作することがゴールで、設計の美しさは気にしません。

MCP Apps のサンプルや事例を見ると、
Node.js / TypeScript を前提としたものが多く見られますが、
今回は python版として 作ってみます。

1.1 今回やったこと(最小構成)

世の中の MCP Apps のサンプルを見ると、

  • Node.js / TypeScript 前提
  • React / Vite 前提

というものが大半です。

今回はあえて、

  • 公式 basic-host はそのまま使う
  • MCP App 側だけを Python(FastMCP)で実装する

という構成を取りました。

理由は単純で、

「言語を変えても詰まるなら、それは仕様理解の問題」

だと考えたからです。

事前準備(basic-hostの起動)

basic-host は、MCP Apps の UI 表示用ホストとして公式に提供されているものを利用します。
本記事では MCP Client(host側)の実装には立ち入りません。
※参考:Claud Desktop もMCP Appsに対応しているようです。

  1. 公式リポジトリをクローンして必要なパッケージをインストールします
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps
npm install
  1. basic-hostだけ起動します
cd examples/basic-host
SERVERS='["http://localhost:3001/mcp"]' npm start

この時、後述で利用するMCP ServerのURLを渡しておきます。
起動時にポートが異なる場合は自身の起動したMCP Serverに合わせて変更してください。

今回動作確認で利用するMCP Appsのサンプルコード

git clone https://github.com/naokky-tech/sample-mcpapp.git
cd sample-mcpapp

python -m venv .venv
source .venv/bin/activate

pip install -U pip
pip install mcp starlette uvicorn

uvicorn server:app --host 127.0.0.1 --port 3001 --reload

2. 最初にハマったmimeType

2.1 そもそも表示されない

MCP Appを実装して最初にハマったのは、「実装したはずのUIが表示されない」というとても単純な現象でした。

解析ステップ 確認したこと 結果
Step 1 MCP Server は起動しているか ✅ 起動している
Step 2 MCP Clientにレスポンスは返っているか ✅ 返っている
Step 3 mimeType は正しいか text/html
Step 4 HTML は壊れていないか ✅ 正しい文字列
Step 5 UI は表示されるか ❌ 表示されない
Step 6 エラーログは出ているか エラーも出ない

「HTML返してるのに、なぜ?」

この時点では
「レスポンスが返ってきている = 表示されるはず」
という Web API 的な思い込みから抜けられていませんでした。

問題のあった実装(抜粋)

以下は、HTML製のカラーピッカーUIをMCPリソースとして公開し、それを起動するツールを用意しているMCP Appのコードです。

APP_MIME = "text/html"
UI_URI = "ui://mcp-basic-host-test/color-picker"

DIST_HTML = Path(__file__).parent / "ui" / "dist" / "mcp-app.html"

@mcp.resource(UI_URI, mime_type=APP_MIME)
def color_picker_ui() -> str:
    return DIST_HTML.read_text(encoding="utf-8")

@mcp.tool(
    name="pick_color",
    description="User needs to pick a color interactively.",
    meta={"ui": {"resourceUri": UI_URI}},
)
def pick_color() -> CallToolResult:
    return CallToolResult(
        content=[TextContent(type="text", text="Open the UI to pick a color.")],
        _meta={"ui": {"resourceUri": UI_URI}},
    )

basic-hostに表示された画面

スクリーンショット右側のWeb Inspectorのログを見るとMCP ServerからHTML情報が返却されていることがわかります。しかし描画まではされていません。

真っ白な画面


2.2 普通のWeb API(HTTP)として考えると見誤っていた

この時点では、まだ「普通のWeb API」として誤解していました。
仕様にも書かれていますが、mime-typeが通常とは異なる点を正しく理解できていなかっただけでした。

  • MCP Apps は通常の Web API とは違う
  • 「ブラウザが直接叩く」前提で考えるとズレる

MCPの公式仕様を見ると正確には以下のような処理となっている。

このフローに示すように
UI Resource として認識されるためには mimeType に profile=mcp-app が必須
であり、単なる text/html では描画対象になりません。
これは HTML の正しさとは無関係 で、「Host 側が UI として扱うかどうかの判定条件」になります。

sdkなどを使って実装するとこの辺りは意識から抜けるポイントになりそうです。

# 正しい定義
APP_MIME = "text/html;profile=mcp-app"

3. CSP(Content Security Policy)が曲者

もう一つ理解に苦しんだ点が、MCP AppにおけるCSPの概念です。

MCP Apps では、CORS は “UI とサーバ間” の問題ではなく、
“Host ↔ MCP Server 間の内部通信” となる点は注視しなければいけません。

MCP AppのCORS

ここを理解できていないと、以下のような問題に遭遇します。

設計する際は

重要な気づき

ここで初めて、「自分は前提を間違えていたのでは?」と疑い始めました。

  • MCP Apps の UI は 制限ありなUI (Webアプリではない、UIフレームワークでもない)
  • 失敗しても「静かに失敗」する
  • DevTools を見ないと何も分からない
  • だが、その分「安全に埋め込める」

つまり MCP Apps の UI は、
「自由度を捨てて、埋め込み安全性を取った UI」 と言えます。

pythonの実装では、以下のような設定が必要になります。

項目 ポイント
allow_origins ブラウザで開いているUIのドメイン ではなく MCP Hostのドメインを許可する
allow_methods OPTIONS だけ通しても意味がない / POST を必ず含める
allow_headers JSON-RPC では content-type が必須

※検証・ローカル用途であれば ["*"] でも問題ありませんが、
本番利用を想定する場合は MCP Host の Origin を明示的に指定する方が安全です。

# MCP Host の Origin を許可する
mcp.app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://mcp-host-sample.example.com",  # ダミー(実際は使用しているHostに合わせる)
    ],
    allow_methods=["POST", "OPTIONS"],
    allow_headers=["content-type"],
)

4. まとめ

4.1 MCP Apps が向いている UI / 向いていない UI

ここまでの実装を通して分かったのは、
MCP Apps で表示すべき UI は、
エージェントの処理フローに、人間の判断を“安全に差し込むための UI” だという点です。

その前提を踏まえると、向き・不向きは以下のように考えることができます。

観点 向いている 向いていない
UIの役割 一時的な判断・確認 常設の操作画面
状態管理 最小・その場限り 複雑・長寿命
画面構成 単画面・簡潔 画面遷移前提
実装思想 「失敗しても安全」 「自由に作り込みたい」

つまり、
UIを作る技術ではなく、UIを挟む技術 と捉えるのが自然です。


4.2 実装のハマりポイント

今回の実装で特にハマりやすかった点を、
「なぜ起きるか」とセットで整理 すると以下のようになります。

ポイント なぜ重要か
UI は最小から作る 表示されない原因の切り分けが困難になる
CSP を最初に意識する 後から直すと構造ごと作り直しになる
module script は慎重に 多くのケースで静かに弾かれる
DevTools 常時 ON エラーが UI 上に一切出ない
動かない=設計ミスと決めつけない Host 側の判定条件に原因があることが多い

特に MCP Apps では、設計や実装以前に
「何も起きない=失敗している」
という前提でデバッグする姿勢が重要だと感じました。


4.3 MCP Apps は「未来のUI」か?

では、MCP Apps は「未来の UI」なのでしょうか。
現時点での個人的な感触としては、次のように考えています。

  • 何でも置き換えるものではない
  • だが 必ず必要になるUI

理由は単純で、

  • エージェントはまだ信用できない
  • 完全自動と人間操作の間に「確認の隙間」がある
  • MCP Apps はその隙間を埋める

この「確認の隙間」をどこに、どの粒度で挟むのがよいのか。
それを考えるところからが、MCP Apps の使いどころなのかもしれません。

Discussion