MCP Apps
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
MCP App — это инструмент с собственным лицом: помимо данных, инструмент указывает на HTML-документ, который хост отображает как интерактивную поверхность.
Две части, всегда две:
- Инструмент, который делает работу и возвращает данные, как любой другой инструмент.
- Ресурс
ui://с HTML, который хост показывает для этого инструмента.
Инструмент несёт ссылку на ресурс в _meta.ui.resourceUri. Хост получает его через resources/read, отображает в изолированном iframe (песочнице) и передаёт результат инструмента в этот 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