你問 Claude「幫我看這個月的支出」,回來的不是一段文字,而是一個會動的圓餅圖——可以 hover 看明細、點類別下鑽。整個圖長在對話裡,不開新分頁。
或者你說「給我一個顏色選擇器」,下方冒出一個有色盤的小元件,選完顏色 Claude 馬上接著用。
這是 MCP App。Tool 回來的東西不是純文字,是一個會在對話裡渲染的互動介面。
跟前一篇 《探索 MCP 協定》 講的 MCP Server 是兩件事——MCP Server 是後端程式、回傳結構化資料;MCP App 是前端介面、塞進對話裡跑。一個 MCP Server 可以同時提供普通 Tool 跟 App 化的 Tool,看你要哪種互動體驗。
這篇用一個計數器當範例,把背後的協定拆開。參考資料是官方 MCP Apps overview 這個說明。
MCP App 不是 MCP Server
容易混淆的點先講清楚。
普通 MCP Tool 的回傳值是 content 陣列,裡面是 text、image、或 resource——Host 把它顯示出來,使用者讀,AI 也讀。沒有互動空間。
MCP App 改寫了「顯示」這一段。Tool 描述裡多了 _meta.ui.resourceUri,指向同一個 Server 提供的另一個 Resource——一段 HTML。Host 看到這個 metadata 後,做的事是:
- 拿 Tool 的 result(普通的 content 陣列)
- 從 Server 抓 UI Resource 拿到 HTML
- 把 HTML 放進對話裡的 sandboxed iframe 渲染
- 把 Tool result 用 postMessage 推給 iframe
iframe 裡的 App 接到資料、畫出畫面。使用者點按鈕的時候,App 能反過來呼叫 Server 的其他 Tool——Host 幫它轉發 JSON-RPC,回來再 push 進 iframe。
整套協定官方稱作 MCP 的 dialect:大部分訊息(tools/call)跟核心協定共用,UI 專屬的訊息以 ui/ 開頭(ui/initialize 之類),傳輸層從 stdio/HTTP 多接了一段 postMessage。
一個 Tool + 一個 ui:// Resource
每個 MCP App 是兩個 MCP primitive 的組合:
| Primitive | 給誰看 | 內容 |
|---|---|---|
| Tool | LLM 跟 Host 都看得到 | description 裡帶 _meta.ui.resourceUri |
Resource(ui://...) | 只有 Host 會抓 | HTML(通常 inline 所有 CSS/JS) |
LLM 不會看到 UI Resource。它看到的只有 Tool description——「這個工具會顯示一個計數器介面」。它呼叫 Tool 之後,UI 由 Host 自己處理;丟回給 LLM 當 context 的,只有 Tool result 裡的文字。
這個分工很重要。LLM 不需要懂 HTML,也不需要看 UI 內容;它只負責決定要不要呼叫 Tool。UI 是給人看的,給 LLM 的是普通的 text content。
Server 端:把 Tool 跟 UI 綁起來
裝相依套件:
# Server 端(Python)
pip install mcp
# UI 端打包(Node,獨立流程)
npm install -D vite vite-plugin-singlefile
mcp 是 MCP 的官方 Python SDK。MCP App 目前只有 TypeScript 版的便利套件 @modelcontextprotocol/ext-apps,Python 端沒有對應 helper,所以下面直接用底層 SDK 把 _meta.ui.resourceUri 標到 Tool 上、用 read_resource 把 HTML 餵出去。UI 端仍然交給 Vite 打包成單檔(理由稍後說明)。
Server 主檔:
# server.py
from pathlib import Path
import mcp.types as types
from mcp.server.lowlevel import Server
server = Server("counter-app")
RESOURCE_URI = "ui://counter/app.html"
RESOURCE_MIME_TYPE = "text/html+skybridge" # MCP App spec 規定的 mime type
# 後端持有的計數器狀態
count = 0
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
# 主 Tool:宣告自己有對應的 UI
types.Tool(
name="show_counter",
title="Show Counter",
description="顯示一個可互動的計數器介面。",
inputSchema={"type": "object", "properties": {}},
_meta={"ui": {"resourceUri": RESOURCE_URI}}, # ← 這行讓 Host 認得這是 App
),
# 副 Tool:UI 端按 +1 時會回呼這個
types.Tool(
name="increment",
title="Increment",
description="把計數器加 1。",
inputSchema={"type": "object", "properties": {}},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]:
global count
if name == "show_counter":
return [types.TextContent(type="text", text=str(count))]
if name == "increment":
count += 1
return [types.TextContent(type="text", text=str(count))]
raise ValueError(f"unknown tool: {name}")
# UI Resource:把 vite 編譯出的單檔 HTML 餵給 Host
@server.list_resources()
async def list_resources() -> list[types.Resource]:
return [types.Resource(uri=RESOURCE_URI, name="Counter UI", mimeType=RESOURCE_MIME_TYPE)]
@server.read_resource()
async def read_resource(uri: str) -> str:
if uri == RESOURCE_URI:
path = Path(__file__).parent / "dist" / "mcp-app.html"
return path.read_text(encoding="utf-8")
raise ValueError(f"unknown resource: {uri}")
# 之後是 Streamable HTTP transport(搭 Starlette / uvicorn)的掛載,
# 套常規 mcp Python 寫法,這裡省略。
幾個重點。
_meta.ui.resourceUri 是讓 Host 認得「這個 Tool 有 UI」的標記。沒有這行,show_counter 就只是一個普通 Tool,回傳的數字會被 Host 直接以文字顯示。
Python 這邊沒有 registerAppTool 這類包裝,_meta 直接寫進 types.Tool 的欄位即可。show_counter 跟 increment 都是普通的 Tool,差別只在前者帶 _meta.ui.resourceUri、後者沒有。LLM 看到它們的時候會理解兩個工具的用途,但實務上 LLM 多半只會主動呼叫 show_counter,剩下的 increment 是 App 從 UI 端回呼的。
ui:// 不是真實的 URL scheme,是 MCP 規範自己定義的——Host 看到 ui:// 開頭就知道要透過 Server 的 Resource handler 抓內容。路徑長什麼樣 (ui://counter/app.html) 由你自己決定。
UI 端:跟 Host 雙向對話
UI 跑在 Host 開的 sandboxed iframe 裡,那是瀏覽器的執行環境,所以這段一定是 HTML + JavaScript,不論 Server 端用什麼語言寫都一樣。會用 Vite 把它打成單檔(vite-plugin-singlefile、inline CSS/JS)是因為 iframe 的 CSP 預設 deny,全部塞一檔最省事。
<!-- mcp-app.html -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<title>Counter</title>
<style>
body { font-family: system-ui; padding: 1rem; }
#count { font-size: 3rem; font-weight: bold; }
</style>
</head>
<body>
<div id="count">--</div>
<button id="inc">+1</button>
<script type="module" src="/src/mcp-app.js"></script>
</body>
</html>
// src/mcp-app.js
import { App } from "@modelcontextprotocol/ext-apps";
const countEl = document.getElementById("count");
const incBtn = document.getElementById("inc");
const app = new App({ name: "counter-ui", version: "1.0.0" });
// 跟 Host 建立 postMessage channel
app.connect();
// Host 把 show_counter 的 tool result 推進來時,初始化畫面
app.ontoolresult = (result) => {
const text = result.content?.find((c) => c.type === "text")?.text;
countEl.textContent = text ?? "?";
};
// 使用者按 +1,UI 主動呼叫 server tool
incBtn.addEventListener("click", async () => {
const result = await app.callServerTool({
name: "increment",
arguments: {},
});
const text = result.content?.find((c) => c.type === "text")?.text;
countEl.textContent = text ?? "?";
});
App 類別是 ext-apps 的 client wrapper,把 postMessage 細節包起來。
app.connect() 跟 Host 完成 ui/initialize 交握。沒呼叫的話 Host 不會推資料進來。
app.ontoolresult 是 callback——Host 推進新的 Tool result 時會觸發。場景:使用者第一次叫 show_counter、或這個 App 在多輪對話裡被重新顯示。
app.callServerTool() 是反向呼叫——App 主動請 Host 幫你打 Server 上的某個 Tool。底層是 App 透過 postMessage 送一個 JSON-RPC tools/call 給 Host,Host 轉發到 Server,再把結果 push 回來。
整個雙向流程:
Host 可以在 LLM 還沒呼叫 show_counter 之前就把 UI resource 預先抓下來載進 iframe——spec 允許這個 preload 行為,是為了支援「streaming tool inputs to the app」這類更進階的場景。實作上你可以假設 App 跟 tool result 大致同步抵達,但別假設誰先誰後。
Server 端的 count 是真的後端狀態。同一個對話視窗開兩個 counter App,按其中一個的 +1,另一個重新整理會看到加過。狀態在哪、誰是 source of truth,是 App 設計第一個要決定的事——整個放後端最簡單、放前端最快但會跟 LLM 的 context 脫節、兩邊都放最常見也最容易出 bug。
範例為了精簡只用 content: [{ type: "text", text: ... }] 把數字塞成字串。資料變複雜時,tool result 可以多回一個 structuredContent 欄位放結構化 JSON——App 端從 result.structuredContent 直接拿物件,不用解析文字。contra-mcp-app 就用這個欄位傳整個 game session 的資料給 UI。
Sandbox、CSP 與 fallback
iframe sandbox
UI Resource 是別人寫的程式碼。Host 不能無條件信任它——它可能來自第三方 MCP Server,可能會嘗試讀 Host 主頁面的 cookie、storage、DOM。
對策:把 HTML 塞進 sandboxed iframe,預設拒絕一切。不能存取父頁面、不能讀 cookie、不能跳外連。所有跟 Host 的溝通都走 postMessage。
CSP
CSP 也是 deny by default。要載外部 script、字型、圖片,得在 _meta.ui.csp 裡明寫白名單:
_meta={
"ui": {
"resourceUri": RESOURCE_URI,
"csp": {
"resourceDomains": ["https://cdn.jsdelivr.net"], # script/img/style/font/media
"connectDomains": ["https://api.example.com"], # fetch / XHR / WebSocket
"frameDomains": ["https://www.youtube.com"], # 嵌套 iframe
"baseUriDomains": ["https://cdn.example.com"], # <base href="...">
},
},
}
注意這四個 key 不是原生 CSP 的 directive 名稱,是 spec 自己定義的語意分組——一個 resourceDomains 會展開成 script-src、img-src、style-src、font-src、media-src 五條 CSP 指令。沒列到的 key 預設 'none',瀏覽器直接擋。
Vite + vite-plugin-singlefile 把所有 JS/CSS inline 進 HTML 就是為了避開這個——一檔到底,沒有外部依賴要白名單。
不支援 MCP App 的 Host 怎麼辦
MCP App 是 MCP 的 extension,不是核心。Claude、Claude Desktop、VS Code Copilot、Goose、Postman、MCPJam 目前支援;其他 client 可能還沒跟上。
不支援的 Host 看到帶 _meta.ui.resourceUri 的 Tool,會把 metadata 忽略掉,只用 Tool result 的 text content。所以 Tool result 的 content 不要寫成「請看下方介面」這種對 UI 有依賴的話——應該寫成「目前計數:3」這種也能單獨讀懂的訊息。
實作 MCP App 時要先想:「假設 Host 不支援 App、只看到我的 text content,使用者還能用嗎?」答案是 yes 的話,這個 App 才算寫得穩。
結語
MCP App 把 AI 對話從「文字進文字出」推到「文字進、互動介面出」。表面上多了一個 UI 層,內裡還是同一套協定——一個 Tool 多一個 metadata、一個 Resource 換成 HTML、傳輸從 stdio/HTTP 加上 postMessage 一段。
但「多一個 UI 層」改變的設計面比想像中多。狀態放哪?App 端跟 Server 端的權責怎麼切?不支援 App 的 Host 看到的會是什麼?這些問題不解決,App 在你機器上能跑,部署到使用者那一側就會出包。
下次你看到 Claude 對話裡冒出一個會動的小元件,你會知道那不是魔法——是一個 Tool 帶了 metadata、一個 ui:// Resource 被 Host 抓出來、丟進 sandboxed iframe、靠 postMessage 在跟 Server 兩邊對話。