Перейти до змісту

MCP Apps

Машинний переклад

Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.

MCP App — це інструмент із власним обличчям: поряд із даними інструмент указує на HTML-документ, який хост відображає як інтерактивну поверхню.

Дві частини, завжди дві частини:

  1. Інструмент, який виконує роботу й повертає дані, як будь-який інший інструмент.
  2. Ресурс ui:// з HTML, який хост показує для нього.

Інструмент несе посилання на ресурс у _meta.ui.resourceUri. Хост отримує його через resources/read, відображає в ізольованому iframe (sandbox) і передає результат інструмента в цей iframe через postMessage. Ваш сервер ніколи не надсилає й не отримує жодних повідомлень 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-тип каже хосту: «це застосунок, відобрази його».
  • 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 ще раз:

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')]

client_supports_apps(ctx) дорівнює True лише тоді, коли клієнт оголосив розширення io.modelcontextprotocol/ui і вказав text/html;profile=mcp-app у своїх налаштуваннях mimeTypes. Поле обов'язкове, тож клієнт, який його пропустив, не зараховується. Саме це й оголошує 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])

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 на диску чи згенерованого вмісту — побудуйте ресурс самі й передайте його:

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])

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