Phantom.App behaviour (phantom_mcp v0.5.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.