Behaviour for MCP App UI resources.
MCP Apps are interactive
HTML interfaces that render inside MCP hosts (like Claude Desktop) as
sandboxed iframes. Phantom.App works like a Phoenix Controller — it's
a Plug pipeline that renders HTML and provides metadata to the host.
Client-side JavaScript is required
The rendered HTML must include JavaScript from the
@modelcontextprotocol/ext-apps
npm package. This JS handles the postMessage handshake with the host.
Without it, the host renders a blank iframe.
See the MCP Apps JS SDK for the full client-side API, including theming, tool input/result handling, and host communication.
Quick Start
- Define your app module
- Register it on a tool in your MCP router
- Bundle the ext-apps JavaScript client
- Include the bundle in your root layout via a base64 data URI
App module
defmodule MyApp.MCP.DashboardApp do
use Phantom.App,
permissions: [:clipboard_write],
prefers_border: true
use Phoenix.Component
import Phoenix.Controller, only: [put_root_layout: 2, put_layout: 2]
plug :put_root_layout, html: {MyAppWeb.MCP.Layouts, :root}
plug :put_layout, html: {MyAppWeb.MCP.Layouts, :app}
plug Phantom.App.CSP,
connect_domains: ["https://api.example.com"]
@impl Phantom.App
def mount(_params, session) do
{:ok, %{user: session.assigns.user}}
end
@impl Phantom.App
def render(assigns) do
~H"""
<h1>Hello {@user.name}</h1>
"""
end
endRouter registration
defmodule MyApp.MCP.Router do
use Phantom.Router, name: "MyApp", vsn: "1.0"
@description "Open the dashboard"
tool :dashboard, app: MyApp.MCP.DashboardApp
def dashboard(_params, session), do: {:reply, Phantom.Tool.text("ok"), session}
endLayout with JavaScript bundle
The ext-apps JS bundle must be base64-encoded and loaded via a
data: URI. Inline <script> tags break because the host injects
the HTML via document.write() and minified JS contains backticks
that conflict with the host's parser.
defmodule MyAppWeb.MCP.Layouts do
use Phoenix.Component
@mcp_app_js_path Path.join(:code.priv_dir(:my_app), "static/mcp_app.js")
@external_resource @mcp_app_js_path
@mcp_app_js_b64 @mcp_app_js_path |> File.read!() |> Base.encode64()
def root(assigns) do
assigns = assign(assigns, :mcp_app_js_b64, @mcp_app_js_b64)
~H"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<script src={"data:text/javascript;base64,#{@mcp_app_js_b64}"}></script>
</head>
<body>{@inner_content}</body>
</html>
"""
end
endFor a complete walkthrough including the JavaScript entry point, event handling, esbuild configuration, and framework-specific examples (React, Vue, Svelte), see the Building MCP Apps guide.
Options
:permissions- Sandbox permissions::camera,:microphone,:geolocation,:clipboard_write:domain- Dedicated sandbox origin hint:prefers_border- Whether the app prefers a visible border
These can also be set dynamically via put_permissions/2,
put_domain/2, and put_prefers_border/2.
Visibility
Tools with an app: control who can see and invoke them via the
visibility option on the tool's :ui metadata. Visibility is a
list of audience strings:
"model"— the tool appears intools/listand the LLM can call it. This is the normal path: the model decides when to invoke the tool, and the host renders the app UI alongside the result."app"— the tool can be called by other MCP App UIs running in the same session (viaapp.callServerTool()). This allows one app to compose with another's tools.
The default visibility is [:model, :app] — both the model and
other apps can see and call the tool.
Common patterns:
# Default: model and apps can both call it
tool :dashboard, app: MyApp.DashboardApp
# App-only: hidden from the model, only callable from other apps.
# Useful for helper tools that power an app's UI but shouldn't
# clutter the model's tool list.
tool :fetch_chart_data, app: MyApp.ChartDataApp,
ui: [visibility: [:app]]
# Model-only: the model can call it but other apps cannot.
tool :admin_panel, app: MyApp.AdminApp,
ui: [visibility: [:model]]Dev Preview
Mount Phantom.App.Preview in your router during development to
browse and test your MCP Apps in the browser:
# Phoenix Router
if Mix.env() == :dev do
forward "/mcp-apps", Phantom.App.Preview,
router: MyApp.MCP.Router,
mcp_endpoint: "/mcp"
endVisit /mcp-apps to see a list of registered apps with iframe
previews. The preview connects to your MCP server so interactive
features (tool calls, resource listing) work end-to-end. See
Phantom.App.Preview for details.
Summary
Functions
The restrictive default CSP per the MCP Apps spec.
Set the sandbox domain on the conn for _meta.ui.domain.
Set sandbox permissions on the conn for _meta.ui.permissions.
Set the border preference on the conn for _meta.ui.prefersBorder.
Convert render output to an HTML binary string.
Types
Output accepted from render/1.
Plain strings, iodata, and any struct that implements Phoenix.HTML.Safe
(e.g. Phoenix.LiveView.Rendered from ~H) are all valid.
Callbacks
@callback mount(params :: map(), session :: Phantom.Session.t()) :: {:ok, map()}
Functions
The restrictive default CSP per the MCP Apps spec.
Applied automatically when no Phantom.App.CSP plug overrides it.
@spec put_domain(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
Set the sandbox domain on the conn for _meta.ui.domain.
@spec put_permissions(Plug.Conn.t(), [atom()]) :: Plug.Conn.t()
Set sandbox permissions on the conn for _meta.ui.permissions.
@spec put_prefers_border(Plug.Conn.t(), boolean()) :: Plug.Conn.t()
Set the border preference on the conn for _meta.ui.prefersBorder.
Convert render output to an HTML binary string.