defmodule Phantom.App.Preview do @moduledoc """ Development preview plug for MCP App resources. Lists registered MCP Apps and interact with them in the browser.   ## Usage Mount in your router during development ### Phoenix Router ```elixir if Mix.env() == :dev do forward "/dev/mcp-apps", Phantom.App.Preview, router: MyApp.MCPRouter end ``` ### Plug Router ```elixir if Mix.env() == :dev do forward "/dev/mcp-apps", to: Phantom.App.Preview, init_opts: [router: MyApp.MCPRouter] end ``` Then visit `/mcp-apps` to see a list of registered apps, and click one to render it in a sandboxed iframe that simulates how MCP hosts display apps. > #### Development only {: .warning} > > This plug is intended for development use only. Do not mount it > in production as it renders app content without authentication. """ @behaviour Plug import Plug.Conn @impl Plug def init(opts) do router = Keyword.fetch!(opts, :router) mcp_endpoint = Keyword.get(opts, :mcp_endpoint) %{router: router, mcp_endpoint: mcp_endpoint} end @impl Plug def call(conn, %{router: router} = opts) do mcp_endpoint = opts[:mcp_endpoint] Phantom.Cache.register(router) case conn.path_info do [] -> send_html(conn, render_index(list_app_templates(router), conn)) ["_sandbox"] -> send_sandbox_proxy(conn) ["_assets", filename] -> serve_static(conn, filename) [name, "frame"] -> case render_app(router, name, conn) do {:ok, html, _csp} -> send_html(conn, render_frame(name, html, conn, mcp_endpoint)) :not_found -> conn |> send_resp(404, "App not found: #{name}") |> halt() end [name] -> case render_app(router, name, conn) do {:ok, html, csp_header} -> conn |> maybe_put_csp(csp_header) |> send_html(html) :not_found -> conn |> send_resp(404, "App not found: #{name}") |> halt() end _ -> conn |> send_resp(404, "Not found") |> halt() end end # -- Routing helpers ------------------------------------------------------- defp list_app_templates(router) do nil |> Phantom.Cache.list(router, :resource_templates) |> Enum.filter(&(&1.scheme == "ui")) end defp render_app(router, name, conn) do case Enum.find(list_app_templates(router), &(&1.name == name)) do nil -> :not_found template -> session = Phantom.Session.new("preview-#{name}") args = if function_exported?(template.handler, template.function, 3) do [%{}, session, conn] else [%{}, session] end case apply(template.handler, template.function, args) do {:reply, %{contents: [%{text: html} | _]} = result, _session} -> csp = extract_csp_from_meta(result[:_meta]) {:ok, html, csp} {:reply, %{text: html}, _session} -> {:ok, html, nil} _ -> {:ok, "
Error rendering app: #{name}
", nil} end end end defp extract_csp_from_meta(%{ui: %{csp: csp}}) when map_size(csp) > 0 do Phantom.App.CSP.build( connect_domains: Map.get(csp, :connectDomains, []), resource_domains: Map.get(csp, :resourceDomains, []), frame_domains: Map.get(csp, :frameDomains, []), base_uri_domains: Map.get(csp, :baseUriDomains, []) ) end defp extract_csp_from_meta(_), do: nil defp maybe_put_csp(conn, nil), do: conn defp maybe_put_csp(conn, csp), do: put_resp_header(conn, "content-security-policy", csp) # -- Static assets --------------------------------------------------------- @allowed_assets ~w[preview.js preview.css] defp serve_static(conn, filename) when filename in @allowed_assets do path = Path.join(Application.app_dir(:phantom_mcp, "priv/static"), filename) case File.read(path) do {:ok, content} -> conn |> put_resp_content_type(mime_for(filename)) |> put_resp_header("cache-control", "no-cache, no-store, must-revalidate") |> send_resp(200, content) |> halt() {:error, _} -> conn |> send_resp(404, "Not found") |> halt() end end defp serve_static(conn, _filename) do conn |> send_resp(404, "Not found") |> halt() end defp mime_for(filename) do case Path.extname(filename) do ".js" -> "application/javascript" ".css" -> "text/css" _ -> "application/octet-stream" end end # -- Sandbox proxy --------------------------------------------------------- defp send_sandbox_proxy(conn) do # Sandbox proxy per the @mcp-ui/client walkthrough. # The proxy receives app HTML from the host via postMessage, then # document.write()s it into its OWN document (same-origin). # This is the expected architecture for AppBridge/AppRenderer. html = """ """ conn |> put_resp_content_type("text/html") |> send_resp(200, html) |> halt() end defp send_html(conn, html) do conn |> put_resp_content_type("text/html") |> send_resp(200, html) |> halt() end defp base_path(conn) do "/" <> Enum.join(conn.script_name, "/") end defp assets_path(conn), do: base_path(conn) <> "/_assets" # Bust per-request in dev so iterating on preview.css/js doesn't require # clearing the browser cache. The Preview plug is dev-only anyway. defp asset_version, do: System.system_time(:millisecond) |> Integer.to_string() # -- Templates ------------------------------------------------------------- defp render_index(templates, conn) do base = base_path(conn) assets = assets_path(conn) items = Enum.map_join(templates, "\n", fn t -> desc = t.description || "No description provided." uri = t.uri_template || "" """#{desc}
#{if uri != "", do: ~s(#{uri}), else: ""}
No app resources registered
Define a resource with scheme: "ui" in your MCP router.
#{count} #{label} registered