Phantom.App behaviour (phantom_mcp v0.5.0-rc.0)

Copy Markdown View Source

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

  1. Define your app module
  2. Register it on a tool in your MCP router
  3. Bundle the ext-apps JavaScript client
  4. 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
end

Router 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}
end

Layout 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
end

For 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 in tools/list and 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 (via app.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"
end

Visit /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

Types

Output accepted from render/1.

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

rendered()

@type rendered() :: binary() | iodata() | struct()

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

mount(params, session)

@callback mount(params :: map(), session :: Phantom.Session.t()) :: {:ok, map()}

render(assigns)

@callback render(assigns :: map()) :: rendered()

Functions

default_csp()

The restrictive default CSP per the MCP Apps spec.

Applied automatically when no Phantom.App.CSP plug overrides it.

put_domain(conn, domain)

@spec put_domain(Plug.Conn.t(), String.t()) :: Plug.Conn.t()

Set the sandbox domain on the conn for _meta.ui.domain.

put_permissions(conn, permissions)

@spec put_permissions(Plug.Conn.t(), [atom()]) :: Plug.Conn.t()

Set sandbox permissions on the conn for _meta.ui.permissions.

put_prefers_border(conn, prefers_border)

@spec put_prefers_border(Plug.Conn.t(), boolean()) :: Plug.Conn.t()

Set the border preference on the conn for _meta.ui.prefersBorder.

to_html(content)

@spec to_html(binary() | iodata() | struct()) :: binary()

Convert render output to an HTML binary string.