# `Phantom.App`
[🔗](https://github.com/dbernheisel/phantom_mcp/blob/main/lib/phantom/app.ex#L1)

Behaviour for MCP App UI resources.

[MCP Apps](https://apps.extensions.modelcontextprotocol.io/) 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 {: .warning}
>
> The rendered HTML **must** include JavaScript from the
> [`@modelcontextprotocol/ext-apps`](https://www.npmjs.com/package/@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](https://github.com/modelcontextprotocol/ext-apps)
> 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

<!-- tabs-open -->

### 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

<!-- tabs-close -->

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](`e:phantom_mcp:mcp_apps.md`)
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.

# `rendered`

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

Output accepted from `c:render/1`.

Plain strings, iodata, and any struct that implements `Phoenix.HTML.Safe`
(e.g. `Phoenix.LiveView.Rendered` from `~H`) are all valid.

# `mount`

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

# `render`

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

# `default_csp`

The restrictive default CSP per the MCP Apps spec.

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

# `put_domain`

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

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

# `put_permissions`

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

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

# `put_prefers_border`

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

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

# `to_html`

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

Convert render output to an HTML binary string.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
