MCP Apps
Машинний переклад
Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.
MCP App — це інструмент із власним обличчям: поряд із даними інструмент указує на HTML-документ, який хост відображає як інтерактивну поверхню.
Дві частини, завжди дві частини:
- Інструмент, який виконує роботу й повертає дані, як будь-який інший інструмент.
- Ресурс
ui://з HTML, який хост показує для нього.
Інструмент несе посилання на ресурс у _meta.ui.resourceUri. Хост отримує його через resources/read, відображає в ізольованому iframe (sandbox) і передає результат інструмента в цей iframe через postMessage. Ваш сервер ніколи не надсилає й не отримує жодних повідомлень ui/*: цей обмін відбувається між хостом та iframe. Ви віддаєте інструмент і HTML-документ, а всю виставу ставить хост.
SDK постачає це як вбудоване розширення Apps (io.modelcontextprotocol/ui). Якщо розширення для вас новинка, спершу прогляньте ту сторінку. Одна хвилина — і повертайтеся.
Годинник із циферблатом
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-тип каже хосту: «це застосунок, відобрази його».MCPServer("clock", extensions=[apps]): увімкнення. Тепер сервер оголошуєio.modelcontextprotocol/uiуcapabilities.extensions.
Сам HTML слухає postMessage від хоста й показує результат. Для справжніх застосунків використовуйте всередині HTML офіційний браузерний SDK @modelcontextprotocol/ext-apps. Він дає ontoolresult, callServerTool, getHostContext і onhostcontextchanged замість сирих подій повідомлень.
Плавна деградація
Не кожен клієнт відображає застосунки. Специфікація прямо каже, що це означає для вас:
Інструменти МУСЯТЬ повертати змістовний масив
content, навіть коли UI доступний.
Модель читає content; iframe — для людей. Хост із підтримкою UI все одно передає текстовий результат моделі, а суто текстовий клієнт отримує лише його. Тож канонічний шаблон — один інструмент, дві відповіді. Погляньте на 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 і вказав text/html;profile=mcp-app у своїх налаштуваннях mimeTypes. Поле обов'язкове, тож клієнт, який його пропустив, не зараховується. Саме це й оголошує 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 — це запити до хоста, а не поведінка сервера. Хост будує з них Content-Security-Policy і Permissions-Policy для iframe й може відмовити. Перевіряйте наявність можливостей у своєму 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 без відповідного зареєстрованого ресурсу, дає
ValueError, колиMCPServer(extensions=[apps])приймає розширення. Інструмент, що оголошує HTML, який повертає 404 наresources/read, — це помилка конфігурації, тож сервер відмовляється створюватися. meta={"ui": ...}у@apps.tool()даєValueError. Декоратор володіє_meta["ui"]; висловлюйте це черезresource_uri=іvisibility=. Інші ключіmeta=спокійно зливаються поруч.
Ані TypeScript SDK ext-apps, ані 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, коли ресурс не задає його явно, і відхиляє явну невідповідність: ресурс ui:// з будь-яким іншим MIME-типом не відобразить жоден хост.
Tip
Орієнтуєтеся на хост до GA-версії, який досі читає застарілий плоский ключ _meta["ui/resourceUri"]? Додайте його самі:
@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}).
Вкладений об'єкт ui — це форма за специфікацією; плоский ключ доживає своє.
Приклад у дії
Історія apps в examples/stories/ — це ця сторінка у вигляді пари, яку можна запустити: сервер з інструментом-годинником із прив'язаним UI та клієнт, який узгоджує Apps, читає _meta.ui.resourceUri інструмента, отримує HTML і викликає інструмент.
uv run python -m stories.apps.client