跳到主要內容
技術

動手寫一個 MCP App:把互動介面塞進對話裡

普通 MCP Tool 只能回文字或 JSON。MCP App 讓 Tool 直接回一個會在對話裡的 sandboxed iframe 渲染的 HTML 介面。本文用一個計數器範例拆開背後的兩個 primitive、postMessage 雙向通訊、與 sandbox 安全模型。

你問 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 後,做的事是:

  1. 拿 Tool 的 result(普通的 content 陣列)
  2. 從 Server 抓 UI Resource 拿到 HTML
  3. 把 HTML 放進對話裡的 sandboxed iframe 渲染
  4. 把 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給誰看內容
ToolLLM 跟 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_counterincrement 都是普通的 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 回來。

整個雙向流程:

MCP App 雙向通訊流程

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-srcimg-srcstyle-srcfont-srcmedia-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 兩邊對話。