MCP Apps
MCP App とは、見た目を持つツールのことです。データと並んで、ツールがホストに対話型の画面として描画させる HTML ドキュメントを指し示します。
構成要素は 2 つで、常にこの 2 つです。
- ツール。ほかのツールと同じように、処理を行ってデータを返します。
ui://リソース。ホストがそのツールのために表示する HTML を収めます。
ツールは _meta.ui.resourceUri でリソースを参照します。ホストはそれを resources/read で取得し、サンドボックス化された iframe に描画し、ツールの結果を postMessage 経由でその iframe に送り込みます。サーバーが ui/* メッセージを送受信することは一切ありません。そのやり取りはホストと iframe の間のものです。サーバーが提供するのはツールと HTML ドキュメントだけで、演出はホストが担当します。
SDK はこれを組み込みの Apps 拡張(io.modelcontextprotocol/ui)として提供しています。拡張になじみがなければ、先にそのページにざっと目を通してください。1 分で済みます。それから戻ってきてください。
見た目のある時計
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T12:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
やることは 4 つです。
Apps():1 つのインスタンスが、UI に紐づくツールとそのリソースをまとめて保持します。@apps.tool(resource_uri="ui://clock/app.html"):通常のツールに_meta.ui.resourceUriの印を加えたものです。@mcp.tool()が受け付けるもの(name、title、description など)はすべてそのまま渡せます。apps.add_html_resource("ui://clock/app.html", CLOCK_HTML):対応するリソースで、text/html;profile=mcp-appとして提供されます。この MIME タイプこそが、ホストに「これはアプリなので描画せよ」と伝える目印です。MCPServer("clock", extensions=[apps]):オプトインします。これでサーバーはcapabilities.extensionsの下でio.modelcontextprotocol/uiを公開します。
HTML 自体はホストの postMessage を待ち受けて結果を表示します。本格的なアプリでは、HTML の中で公式の @modelcontextprotocol/ext-apps ブラウザー SDK を使ってください。生のメッセージイベントの代わりに ontoolresult、callServerTool、getHostContext、onhostcontextchanged が使えます。
グレースフルデグラデーション
すべてのクライアントがアプリを描画するわけではありません。それが何を意味するかについて、仕様は率直です。
ツールは、UI が利用できる場合でも、意味のある
content配列を返さなければなりません。
モデルが読むのは content で、iframe は人間のためのものです。UI に対応したホストでもテキストの結果はモデルに渡されますし、テキスト専用のクライアントはそれ「だけ」を受け取ります。ですから定番のパターンは「1 つのツール、2 つの答え」です。もう一度 get_time を見てください。
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T12:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
client_supports_apps(ctx) が True になるのは、クライアントが io.modelcontextprotocol/ui 拡張を宣言し、かつ mimeTypes 設定に text/html;profile=mcp-app を含めている場合だけです。このフィールドは必須なので、省略したクライアントは該当しません。同じファイルの main() が宣言しているのはまさにこれです。ネゴシエーションのクライアント側であり、その結果リッチな答えが返ってきます。
Warning
"[Rendered UI]" のようなプレースホルダーを唯一のコンテンツとして返さないでください。フォールバックのテキストが役に立たなければ、そのツールはテキスト専用のすべてのクライアントにとっても、モデル自身にとっても役に立ちません。きちんと文を書いてください。
iframe を厳しく制限する
セキュリティのメタデータはリソース側が持ちます。iframe が何を読み込めるか、どのブラウザー権限を要求するか、どのようにフレーム内に表示されたいか、です。
from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
from mcp.server.mcpserver import MCPServer
DASHBOARD_HTML = "<!doctype html><title>Dashboard</title><canvas id='chart'></canvas>"
apps = Apps()
@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
def refresh_dashboard() -> str:
"""Refresh the dashboard data."""
return "refreshed"
apps.add_html_resource(
"ui://dashboard/app.html",
DASHBOARD_HTML,
title="Dashboard",
csp=ResourceCsp(connect_domains=["https://api.example.com"]),
permissions=ResourcePermissions(clipboard_write={}),
domain="dashboard.example.com",
prefers_border=True,
)
mcp = MCPServer("dashboard", extensions=[apps])
csp と permissions はホストへの要望であって、サーバーの振る舞いではありません。ホストはそれらをもとに iframe の Content-Security-Policy と Permissions-Policy を組み立てますが、拒否することもあります。許可されたと決めつけず、JS 側で機能検出してください。
ResourceCsp をフィールドごとに示します(Python の名前、通信上のキー、ホストがそれで何をするか)。
| Python | 通信上のキー(_meta.ui.csp) |
制御対象 |
|---|---|---|
connect_domains |
connectDomains |
connect-src:fetch や XHR の接続先 |
resource_domains |
resourceDomains |
img-src、style-src など:静的アセット |
frame_domains |
frameDomains |
frame-src:入れ子の iframe |
base_uri_domains |
baseUriDomains |
base-uri:<base> が指せる先 |
ResourcePermissions:各フィールドが iframe 用のブラウザー権限を要求します。
| Python | 通信上のキー(_meta.ui.permissions) |
|---|---|
camera |
camera |
microphone |
microphone |
geolocation |
geolocation |
clipboard_write |
clipboardWrite |
Note
CSP と権限はリソースに置くもので、ツールには決して置きません。仕様のツールメタデータにはそれらの入る場所がなく、そこに置いてもホストは無視します。SDK ではこの間違いをそもそも表現できないようにしています。@apps.tool() には csp パラメーターが存在しません。
可視性
ツールに visibility=["app"] を付けると、「これはモデルのためではなく iframe のために存在する」という意味になります。
"model":モデルが呼び出せます。"app":iframe が(callServerTool経由で)呼び出せます。- 省略:両方。これがデフォルトです。
フィルタリングはホストの仕事です。サーバーはアプリ専用のツールもほかのツールと同じように tools/list に載せ、ホストがそれをモデルから隠します。サーバー側でフィルタリングしないでください。
SDK が強制するルール
これらはすべて、本番ではなく起動時に失敗します。
resource_uriやリソース URI がui://...でない場合、デコレート時または登録時にValueErrorになります。- 対応する登録済みリソースのない URI に紐づけられたツールは、
MCPServer(extensions=[apps])が拡張を取り込む時点でValueErrorになります。resources/readで 404 になる HTML を公開するツールは設定ミスなので、構築を拒否します。 @apps.tool()にmeta={"ui": ...}を渡すとValueErrorになります。_meta["ui"]はデコレーターの管轄です。resource_uri=とvisibility=で指定してください。ほかのmeta=キーは問題なく一緒にマージされます。
現時点では、TypeScript の ext-apps SDK も FastMCP もこれらをどれも検出しません。ホストより先に自分で気づけるほうがよいと考えています。
インライン HTML の先へ
add_html_resource はよくあるケース、つまり HTML の文字列を扱います。それ以外、たとえばディスク上の HTML や生成されたコンテンツでは、リソースを自分で組み立てて渡してください。
from pathlib import Path
from mcp.server.apps import Apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FileResource
REPORT_HTML = Path(__file__).parent / "report.html"
apps = Apps()
@apps.tool(resource_uri="ui://report/app.html")
def refresh_report() -> str:
"""Refresh the report data."""
return "report refreshed"
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
mcp = MCPServer("report", extensions=[apps])
add_resource は、リソースに MIME タイプが明示されていなければ text/html;profile=mcp-app を補い、明示的な不一致は拒否します。ほかの MIME タイプの ui:// リソースは、どのホストも描画しないリソースだからです。
Tip
非推奨のフラットな _meta["ui/resourceUri"] キーをまだ読んでいる GA 前のホストを対象にしていますか? 自分でマージしてください。@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}) と書きます。入れ子の ui オブジェクトが仕様の形で、フラットなキーはいずれなくなります。
動かしてみる
examples/stories/ の apps ストーリーは、このページを実行可能なペアにしたものです。UI に紐づく時計ツールを持つサーバーと、Apps をネゴシエートしてツールの _meta.ui.resourceUri を読み、HTML を取得してツールを呼び出すクライアントです。
uv run python -m stories.apps.client