MCP Apps
Tradução automática
Esta página foi traduzida automaticamente a partir da documentação em inglês, e a página em inglês é a versão de referência. Se algo parecer errado, Traduções explica como avisar.
Um MCP App é uma ferramenta (tool) com uma cara: junto com os dados, a ferramenta aponta para um documento HTML que o host renderiza como uma superfície interativa.
Duas partes, sempre duas partes:
- Uma ferramenta que faz o trabalho e retorna dados, como qualquer outra ferramenta.
- Um recurso
ui://contendo o HTML que o host mostra para ela.
A ferramenta carrega uma referência _meta.ui.resourceUri ao recurso. O host busca esse
recurso com resources/read, renderiza em um iframe em sandbox e envia o resultado
da ferramenta para dentro desse iframe via postMessage. Seu servidor nunca envia nem recebe
nenhuma mensagem ui/*: esse tráfego fica entre o host e o iframe. Você serve uma ferramenta
e um documento HTML; o host cuida do espetáculo.
O SDK entrega isso como a extensão embutida Apps (io.modelcontextprotocol/ui).
Se Extensões são novidade para você, dê uma olhada naquela página primeiro. Um minuto,
e depois volte aqui.
Um relógio com uma cara
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')]
Quatro movimentos:
Apps(): uma única instância guarda suas ferramentas ligadas a UI e os recursos delas.@apps.tool(resource_uri="ui://clock/app.html"): uma ferramenta comum, mais o carimbo_meta.ui.resourceUri. Tudo o que@mcp.tool()aceita (name, title, description, ...) passa direto.apps.add_html_resource("ui://clock/app.html", CLOCK_HTML): o recurso correspondente, servido comotext/html;profile=mcp-app. É exatamente esse MIME type que diz ao host "isto é um app, renderize".MCPServer("clock", extensions=[apps]): você opta por participar. O servidor agora anunciaio.modelcontextprotocol/uiemcapabilities.extensions.
O HTML em si escuta o postMessage do host e mostra o resultado. Para apps
de verdade, use o SDK de navegador oficial @modelcontextprotocol/ext-apps
dentro do seu HTML. Ele dá a você ontoolresult, callServerTool,
getHostContext e onhostcontextchanged em vez de eventos de mensagem crus.
Degradação elegante
Nem todo cliente renderiza apps. A especificação é direta sobre o que isso significa para você:
As ferramentas DEVEM retornar um array
contentsignificativo mesmo quando há UI disponível.
O modelo lê content; o iframe é para humanos. Um host com suporte a UI ainda passa
o resultado em texto para o modelo, e um cliente só de texto recebe apenas isso. Então o
padrão canônico é uma ferramenta, duas respostas. Olhe get_time de novo:
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 somente quando o cliente declarou a
extensão io.modelcontextprotocol/ui e listou text/html;profile=mcp-app
nas suas configurações mimeTypes. O campo é obrigatório, então um cliente que o omite
não conta. É exatamente isso que main() no mesmo arquivo declara: a
metade cliente da negociação, e a resposta rica volta.
Warning
Nunca retorne um placeholder como "[Rendered UI]" como único conteúdo. Se o
texto de fallback é inútil, a ferramenta é inútil para todo cliente só de texto e para
o próprio modelo. Escreva a frase.
Trancando o iframe
O lado do recurso carrega os metadados de segurança: o que o iframe pode carregar, quais permissões do navegador ele quer, como gostaria de ser enquadrado:
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 e permissions são pedidos ao host, não comportamento do servidor. O host
monta a Content-Security-Policy e a Permissions-Policy do iframe a partir deles, e
pode recusar. Faça detecção de funcionalidade no seu JS em vez de presumir que foi concedido.
ResourceCsp, campo por campo (nome em Python, chave no protocolo, o que o host faz com ele):
| Python | Protocolo (_meta.ui.csp) |
Controla |
|---|---|---|
connect_domains |
connectDomains |
connect-src: para onde fetch/XHR podem ir |
resource_domains |
resourceDomains |
img-src, style-src, ...: assets estáticos |
frame_domains |
frameDomains |
frame-src: iframes aninhados |
base_uri_domains |
baseUriDomains |
base-uri: para onde <base> pode apontar |
ResourcePermissions: cada campo solicita uma permissão do navegador para o iframe.
| Python | Protocolo (_meta.ui.permissions) |
|---|---|
camera |
camera |
microphone |
microphone |
geolocation |
geolocation |
clipboard_write |
clipboardWrite |
Note
CSP e permissões vivem no recurso, nunca na ferramenta. Os metadados de ferramenta
da especificação não têm lugar para eles, e os hosts os ignoram ali. O SDK torna o
erro irrepresentável: @apps.tool() simplesmente não tem parâmetro csp.
Visibilidade
visibility=["app"] em uma ferramenta diz "isto existe para o iframe, não para o modelo":
"model": o modelo pode chamá-la."app": o iframe pode chamá-la (viacallServerTool).- Omitido: ambos, que é o padrão.
Filtrar é trabalho do host. Seu servidor lista as ferramentas só de app em tools/list
como qualquer outra; o host as esconde do modelo. Não filtre no lado do servidor.
As regras que o SDK impõe
Todas estas falham na inicialização, não em produção:
- Um
resource_uriou URI de recurso que não sejaui://...é umValueErrorno momento da decoração/registro. - Uma ferramenta ligada a uma URI sem recurso registrado correspondente é um
ValueErrorquandoMCPServer(extensions=[apps])consome a extensão. Uma ferramenta que anuncia um HTML que dá 404 emresources/readé uma configuração errada, então o servidor se recusa a ser construído. meta={"ui": ...}em@apps.tool()é umValueError. O decorator é dono de_meta["ui"]; diga isso comresource_uri=evisibility=. Outras chaves emmeta=são mescladas normalmente ao lado.
Nem o SDK ext-apps em TypeScript nem o FastMCP pegam nenhum desses casos hoje; preferimos que você descubra antes que um host descubra.
Além do HTML inline
add_html_resource cobre o caso comum: uma string de HTML. Para qualquer outra coisa,
HTML em disco ou conteúdo gerado, construa o recurso você mesmo e entregue:
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 preenche o MIME type text/html;profile=mcp-app quando o recurso
não define um explicitamente, e rejeita uma incompatibilidade explícita: um recurso ui://
sob qualquer outro MIME type é um que nenhum host vai renderizar.
Tip
Mirando um host pré-GA que ainda lê a chave plana depreciada
_meta["ui/resourceUri"]? Mescle você mesmo:
@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}).
O objeto ui aninhado é o formato da especificação; a chave plana está de saída.
Veja rodando
A história apps em examples/stories/ é esta página como um par executável: um servidor
com uma ferramenta de relógio ligada a UI e um cliente que negocia Apps, lê o
_meta.ui.resourceUri da ferramenta, busca o HTML e chama a ferramenta.
uv run python -m stories.apps.client