Перейти к содержанию

MCP Apps

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

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

Две части, всегда две:

  1. Инструмент, который делает работу и возвращает данные, как любой другой инструмент.
  2. Ресурс ui:// с HTML, который хост показывает для этого инструмента.

Инструмент несёт ссылку на ресурс в _meta.ui.resourceUri. Хост получает его через resources/read, отображает в изолированном iframe (песочнице) и передаёт результат инструмента в этот 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