跳轉至

MCP Apps

機器翻譯

本頁是從英文說明文件自動翻譯而來,以英文頁面為準。如果哪裡讀起來不對勁,翻譯有說明如何回報。

MCP App 是一個有門面的工具:除了資料之外,工具還會指向一份 HTML 文件,由 MCP 主機(host)把它繪製成可互動的介面。

兩個部分,永遠都是兩個部分:

  1. 一個工具,負責做事並回傳資料,跟任何其他工具一樣。
  2. 一個 ui:// 資源,裡面裝著主機要為它顯示的 HTML。

工具帶有一個指向該資源的 _meta.ui.resourceUri 參照。主機用 resources/read 取得它,在沙箱化的 iframe 裡繪製,再透過 postMessage 把工具的結果推進那個 iframe。伺服器從頭到尾不會送出或收到任何 ui/* 訊息:那些流量是主機和 iframe 之間的事。你提供一個工具和一份 HTML 文件,場面由主機負責。

SDK 以內建的 Apps 擴充功能(io.modelcontextprotocol/ui)提供這項功能。如果還不熟悉擴充功能,先快速看過那一頁。一分鐘就好,看完再回來。

有錶面的時鐘

server.py
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')]

四個動作:

  • Apps():一個實例容納所有綁定 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 型別告訴主機「這是個 app,把它繪製出來」。
  • MCPServer("clock", extensions=[apps]):選擇加入。伺服器現在會在 capabilities.extensions 底下宣告 io.modelcontextprotocol/ui

HTML 本身會監聽主機的 postMessage 並顯示結果。真正的 app 請在 HTML 裡使用官方的 @modelcontextprotocol/ext-apps 瀏覽器 SDK。它提供 ontoolresultcallServerToolgetHostContextonhostcontextchanged,不用自己處理原始的訊息事件。

優雅降級

不是每個用戶端都會繪製 app。規格對這代表什麼講得很直白:

Tools MUST return a meaningful content array even when UI is available.

模型讀的是 content;iframe 是給人看的。支援 UI 的主機照樣會把文字結果餵給模型,而純文字用戶端會拿到那個。所以標準做法是一個工具,兩種答案。再看一次 get_time

server.py
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')]

只有當用戶端宣告了 io.modelcontextprotocol/ui 擴充功能,而且在它的 mimeTypes 設定裡列出 text/html;profile=mcp-app 時,client_supports_apps(ctx) 才會是 True。這個欄位是必填的,所以省略它的用戶端不算數。同一個檔案裡的 main() 宣告的正是這些:協商的用戶端那一半,於是回來的是豐富版的答案。

Warning

絕對不要把 "[Rendered UI]" 這類佔位文字當成唯一的內容回傳。如果後備文字沒有用,這個工具對每個純文字用戶端、對模型本身就都沒有用。好好寫那句話。

把 iframe 鎖緊

安全相關的中繼資料放在資源這一側:iframe 可以載入什麼、想要哪些瀏覽器權限、希望怎麼被嵌入:

server.py
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])

csppermissions對主機的請求,不是伺服器的行為。主機用它們建構 iframe 的 Content-Security-Policy 和 Permissions-Policy,而且可能拒絕。在 JS 裡做功能偵測,不要假設一定會獲准。

ResourceCsp 逐欄位說明(Python 名稱、線路上的鍵、主機拿它做什麼):

Python 線路(_meta.ui.csp 控制
connect_domains connectDomains connect-srcfetch/XHR 可以連去哪裡
resource_domains resourceDomains img-srcstyle-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 裡照常列出僅限 app 的工具;主機負責對模型隱藏它們。不要在伺服器端過濾。

SDK 強制執行的規則

這些全都在啟動時就失敗,不會等到上線:

  • resource_uri 或資源 URI 不是 ui://...,會在裝飾/註冊時引發 ValueError
  • 工具綁定到一個沒有對應已註冊資源的 URI,會在 MCPServer(extensions=[apps]) 取用這個擴充功能時引發 ValueError。一個宣稱有 HTML、resources/read 卻 404 的工具是設定錯誤,所以它拒絕建構。
  • @apps.tool() 上的 meta={"ui": ...}ValueError_meta["ui"] 歸裝飾器管;要表達請用 resource_uri=visibility=。其他的 meta= 鍵可以正常一起合併。

目前 TypeScript 的 ext-apps SDK 和 FastMCP 都不會攔下這些;我們寧可讓你比主機早一步發現。

不只是行內 HTML

add_html_resource 涵蓋常見情況:一段 HTML 字串。其他情況,像是磁碟上的 HTML 或動態產生的內容,就自己建立資源再交出去:

server.py
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])

資源沒有明確設定 MIME 型別時,add_resource 會補上 text/html;profile=mcp-app;明確設定卻不相符的則會拒絕:掛在任何其他 MIME 型別底下的 ui:// 資源,沒有任何主機會繪製。

Tip

目標是某個 GA 前的主機,還在讀已棄用的扁平 _meta["ui/resourceUri"] 鍵?自己合併進去:@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