diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c00ac85..3d80735 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -57,9 +57,23 @@ jobs:
- name: Compile (warnings as errors)
run: mix compile --warnings-as-errors
+ - name: Compile without optional dependencies
+ run: mix compile --force --warnings-as-errors --no-optional-deps
+
- name: Check for compile-time dependency cycles
run: mix xref graph --format cycles --label compile-connected --fail-above 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+
+ - name: Install npm dependencies
+ run: npm ci
+
+ - name: Build preview assets
+ run: npm run build
+
- name: Run tests
run: epmd -daemon && mix test
diff --git a/.gitignore b/.gitignore
index 618bfe9..54fa6ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,10 @@ phantom_mcp-*.tar
/.claude/*.local.*
.tool-versions
phantom_mcp
-/bin/phantom-stdio
+
+# Node/JS build artifacts
+/node_modules/
+/test/support/app/priv/static/mcp_app.js
+/test/support/app/priv/static/mcp_preview.js
+/priv/static/preview.css
+/priv/static/preview.js
diff --git a/.mcp.json b/.mcp.json
index 9db9eac..168252d 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -6,7 +6,7 @@
},
"phantom-test-https": {
"type": "http",
- "url": "http://localhost:4000/mcp"
+ "url": "http://localhost:4001/mcp"
},
"phantom-test-stdio": {
"command": "bin/phantom-stdio"
diff --git a/assets/css/preview.css b/assets/css/preview.css
new file mode 100644
index 0000000..92ec8d6
--- /dev/null
+++ b/assets/css/preview.css
@@ -0,0 +1,273 @@
+@import "tailwindcss";
+@source "../../lib/phantom/app/preview.ex";
+@source "../../assets/js/preview.js";
+
+@theme {
+ --color-phantom: oklch(0.65 0.15 280);
+ --color-phantom-glow: oklch(0.72 0.18 280);
+ --color-phantom-dim: oklch(0.45 0.12 280);
+ --color-phantom-surface: oklch(0.22 0.02 270);
+ --color-phantom-surface-hover: oklch(0.26 0.025 270);
+}
+
+/* Phantom dot-grid canvas for the preview artboard. The artboard reflects
+ the simulated host theme (data-host-theme on body), not the OS theme. */
+@utility canvas-bg {
+ background-color: #f4f4f6;
+ background-image: radial-gradient(circle, #d0d0d8 0.75px, transparent 0.75px);
+ background-size: 16px 16px;
+}
+
+[data-host-theme="dark"] .canvas-bg,
+.canvas-bg[data-host-theme="dark"] {
+ background-color: oklch(0.14 0.01 270);
+ background-image: radial-gradient(circle, oklch(0.24 0.015 270) 0.75px, transparent 0.75px);
+}
+
+body[data-host-theme="dark"] #mcp-app-container.canvas-bg {
+ background-color: oklch(0.14 0.01 270);
+ background-image: radial-gradient(circle, oklch(0.24 0.015 270) 0.75px, transparent 0.75px);
+}
+
+/* The iframe is sized by ui/notifications/size-changed in inline mode.
+ This min-height keeps it usable until the first size message arrives
+ (and acts as a floor while content loads). Border follows the host
+ theme so it blends with the canvas. */
+.mcp-app-frame {
+ min-height: 320px;
+ border: 1px solid #d4d4d8;
+}
+
+body[data-host-theme="dark"] .mcp-app-frame {
+ border-color: oklch(0.30 0.015 270);
+}
+
+/* Resize handle — vertical drag bar */
+.mcp-app-frame-handle::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 3px;
+ height: 32px;
+ border-radius: 3px;
+ transition: background 0.2s ease, height 0.2s ease, box-shadow 0.2s ease;
+ @apply bg-zinc-300 dark:bg-zinc-600;
+}
+
+.mcp-app-frame-handle:hover::before,
+.mcp-app-frame-handle.is-dragging::before {
+ height: 48px;
+ background-color: var(--color-phantom);
+ box-shadow: 0 0 8px var(--color-phantom-glow);
+}
+
+/* Drag state */
+body.mcp-resizing,
+body.mcp-resizing * {
+ cursor: ew-resize !important;
+}
+body.mcp-resizing iframe {
+ pointer-events: none !important;
+}
+
+/* Host context controls bar */
+.phantom-control-group {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.phantom-control-label {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: oklch(0.45 0.01 270);
+ white-space: nowrap;
+}
+
+.phantom-control-select {
+ appearance: none;
+ background-color: oklch(0.16 0.01 270);
+ border: 1px solid oklch(0.30 0.015 270);
+ border-radius: 0.375rem;
+ color: oklch(0.65 0.01 270);
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
+ font-size: 0.6875rem;
+ line-height: 1;
+ padding: 0.25rem 1.25rem 0.25rem 0.5rem;
+ cursor: pointer;
+ transition: border-color 0.15s ease, color 0.15s ease;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23717179' stroke-width='1.25' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.375rem center;
+}
+
+.phantom-control-select:hover {
+ border-color: var(--color-phantom-dim);
+ color: oklch(0.80 0.01 270);
+}
+
+.phantom-control-select:focus {
+ outline: none;
+ border-color: var(--color-phantom);
+ box-shadow: 0 0 0 1px var(--color-phantom-dim);
+}
+
+/* ------------------------------------------------------------------ */
+/* Chat simulation */
+/* ------------------------------------------------------------------ */
+
+.mcp-chat-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ /* Top padding is inside the chat so the scrollbar runs the full
+ height of the canvas — putting it on the parent would short the
+ scrollbar by the padding amount. */
+ padding-top: 1rem;
+ max-width: 56rem;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.mcp-chat-message {
+ display: flex;
+ flex-direction: column;
+}
+
+.mcp-chat-user {
+ align-items: flex-end;
+}
+
+.mcp-chat-assistant {
+ align-items: flex-start;
+}
+
+.mcp-chat-bubble {
+ max-width: 80%;
+ padding: 0.625rem 0.875rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.mcp-chat-bubble-user {
+ background-color: oklch(0.55 0.14 280);
+ color: #ffffff;
+ border-radius: 1rem 1rem 0.25rem 1rem;
+}
+
+.mcp-chat-bubble-assistant {
+ background-color: #ffffff;
+ color: #1f2937;
+ border-radius: 1rem 1rem 1rem 0.25rem;
+ border: 1px solid #e5e7eb;
+}
+
+body[data-host-theme="dark"] .mcp-chat-bubble-user {
+ background-color: oklch(0.42 0.10 280);
+ color: oklch(0.96 0.01 270);
+}
+
+body[data-host-theme="dark"] .mcp-chat-bubble-assistant {
+ background-color: oklch(0.22 0.015 270);
+ color: oklch(0.86 0.01 270);
+ border-color: oklch(0.30 0.015 270);
+}
+
+body[data-host-theme="dark"] .mcp-chat-tool-label {
+ color: oklch(0.62 0.01 270);
+}
+
+.mcp-chat-tool-result {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.mcp-chat-tool-label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: #6b7280;
+ padding-left: 0.25rem;
+}
+
+.mcp-chat-tool-body {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+}
+
+/* ------------------------------------------------------------------ */
+/* PiP window */
+/* ------------------------------------------------------------------ */
+
+.mcp-pip-window {
+ position: absolute;
+ bottom: 1rem;
+ right: 1rem;
+ width: 380px;
+ height: 320px;
+ border-radius: 0.75rem;
+ border: 1px solid #d4d4d8;
+ background: #ffffff;
+ box-shadow: 0 8px 32px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(15, 23, 42, 0.04);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ z-index: 20;
+ resize: both;
+ min-width: 240px;
+ min-height: 180px;
+}
+
+.mcp-pip-header {
+ display: flex;
+ align-items: center;
+ padding: 0.375rem 0.75rem;
+ background: #f4f4f5;
+ border-bottom: 1px solid #e4e4e7;
+ cursor: grab;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.mcp-pip-header:active {
+ cursor: grabbing;
+}
+
+.mcp-pip-title {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: #52525b;
+}
+
+body[data-host-theme="dark"] .mcp-pip-window {
+ border-color: oklch(0.35 0.02 270);
+ background: oklch(0.16 0.01 270);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.04);
+}
+
+body[data-host-theme="dark"] .mcp-pip-header {
+ background: oklch(0.20 0.015 270);
+ border-bottom-color: oklch(0.30 0.015 270);
+}
+
+body[data-host-theme="dark"] .mcp-pip-title {
+ color: oklch(0.60 0.01 270);
+}
+
+.mcp-pip-body {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
diff --git a/assets/js/preview.js b/assets/js/preview.js
new file mode 100644
index 0000000..930a34d
--- /dev/null
+++ b/assets/js/preview.js
@@ -0,0 +1,834 @@
+/**
+ * MCP App Preview Host — vanilla JS equivalent of @mcp-ui/client's
+ * AppRenderer. Uses AppBridge + PostMessageTransport with a sandbox
+ * proxy (per the mcp-ui walkthrough) to render MCP Apps in iframes.
+ *
+ * This runs on the preview page (the host side), NOT inside the app iframe.
+ */
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import {
+ AppBridge,
+ PostMessageTransport,
+ UI_EXTENSION_CAPABILITIES,
+} from "@mcp-ui/client";
+
+var IMPLEMENTATION = { name: "phantom-preview", version: "1.0.0" };
+
+// ---------------------------------------------------------------------------
+// Client style presets
+// ---------------------------------------------------------------------------
+
+// Hand-ported from priv/static/claude-desktop.css. The CSS file is the
+// reference document (copied from Claude Desktop itself); this object is
+// what the JS actually consumes. Each --color-* var in the CSS uses
+// `light-dark(a, b)` — split here into separate light/dark entries so
+// the manual theme dropdown can pick a concrete value per side without
+// relying on the browser's color-scheme resolution.
+var CLAUDE_DESKTOP_SHARED = {
+ "--font-sans": "Anthropic Sans, sans-serif",
+ "--font-mono": "ui-monospace, monospace",
+ "--font-weight-normal": "400",
+ "--font-weight-medium": "500",
+ "--font-weight-semibold": "600",
+ "--font-weight-bold": "700",
+ "--font-text-xs-size": "12px",
+ "--font-text-sm-size": "14px",
+ "--font-text-md-size": "16px",
+ "--font-text-lg-size": "20px",
+ "--font-heading-xs-size": "12px",
+ "--font-heading-sm-size": "14px",
+ "--font-heading-md-size": "16px",
+ "--font-heading-lg-size": "20px",
+ "--font-heading-xl-size": "24px",
+ "--font-heading-2xl-size": "28px",
+ "--font-heading-3xl-size": "36px",
+ "--font-text-xs-line-height": "1.4",
+ "--font-text-sm-line-height": "1.4",
+ "--font-text-md-line-height": "1.4",
+ "--font-text-lg-line-height": "1.25",
+ "--font-heading-xs-line-height": "1.4",
+ "--font-heading-sm-line-height": "1.4",
+ "--font-heading-md-line-height": "1.4",
+ "--font-heading-lg-line-height": "1.25",
+ "--font-heading-xl-line-height": "1.25",
+ "--font-heading-2xl-line-height": "1.1",
+ "--font-heading-3xl-line-height": "1",
+ "--border-radius-xs": "4px",
+ "--border-radius-sm": "6px",
+ "--border-radius-md": "8px",
+ "--border-radius-lg": "10px",
+ "--border-radius-xl": "12px",
+ "--border-radius-full": "9999px",
+ "--border-width-regular": "0.5px",
+ "--shadow-hairline": "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
+ "--shadow-sm": "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
+ "--shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
+ "--shadow-lg": "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)",
+};
+
+var CLIENT_PRESETS = {
+ "claude-desktop": {
+ light: Object.assign({
+ "--color-background-primary": "rgba(255, 255, 255, 1)",
+ "--color-background-secondary": "rgba(245, 244, 237, 1)",
+ "--color-background-tertiary": "rgba(250, 249, 245, 1)",
+ "--color-background-inverse": "rgba(20, 20, 19, 1)",
+ "--color-background-ghost": "rgba(255, 255, 255, 0)",
+ "--color-background-info": "rgba(214, 228, 246, 1)",
+ "--color-background-danger": "rgba(247, 236, 236, 1)",
+ "--color-background-success": "rgba(233, 241, 220, 1)",
+ "--color-background-warning": "rgba(246, 238, 223, 1)",
+ "--color-background-disabled": "rgba(255, 255, 255, 0.5)",
+ "--color-text-primary": "rgba(20, 20, 19, 1)",
+ "--color-text-secondary": "rgba(61, 61, 58, 1)",
+ "--color-text-tertiary": "rgba(115, 114, 108, 1)",
+ "--color-text-inverse": "rgba(255, 255, 255, 1)",
+ "--color-text-ghost": "rgba(115, 114, 108, 0.5)",
+ "--color-text-info": "rgba(50, 102, 173, 1)",
+ "--color-text-danger": "rgba(127, 44, 40, 1)",
+ "--color-text-success": "rgba(38, 91, 25, 1)",
+ "--color-text-warning": "rgba(90, 72, 21, 1)",
+ "--color-text-disabled": "rgba(20, 20, 19, 0.5)",
+ "--color-border-primary": "rgba(31, 30, 29, 0.4)",
+ "--color-border-secondary": "rgba(31, 30, 29, 0.3)",
+ "--color-border-tertiary": "rgba(31, 30, 29, 0.15)",
+ "--color-border-inverse": "rgba(255, 255, 255, 0.3)",
+ "--color-border-ghost": "rgba(31, 30, 29, 0)",
+ "--color-border-info": "rgba(70, 130, 213, 1)",
+ "--color-border-danger": "rgba(167, 61, 57, 1)",
+ "--color-border-success": "rgba(67, 116, 38, 1)",
+ "--color-border-warning": "rgba(128, 92, 31, 1)",
+ "--color-border-disabled": "rgba(31, 30, 29, 0.1)",
+ "--color-ring-primary": "rgba(20, 20, 19, 0.7)",
+ "--color-ring-secondary": "rgba(61, 61, 58, 0.7)",
+ "--color-ring-inverse": "rgba(255, 255, 255, 0.7)",
+ "--color-ring-info": "rgba(50, 102, 173, 0.5)",
+ "--color-ring-danger": "rgba(167, 61, 57, 0.5)",
+ "--color-ring-success": "rgba(67, 116, 38, 0.5)",
+ "--color-ring-warning": "rgba(128, 92, 31, 0.5)",
+ }, CLAUDE_DESKTOP_SHARED),
+ dark: Object.assign({
+ "--color-background-primary": "rgba(48, 48, 46, 1)",
+ "--color-background-secondary": "rgba(38, 38, 36, 1)",
+ "--color-background-tertiary": "rgba(20, 20, 19, 1)",
+ "--color-background-inverse": "rgba(250, 249, 245, 1)",
+ "--color-background-ghost": "rgba(48, 48, 46, 0)",
+ "--color-background-info": "rgba(37, 62, 95, 1)",
+ "--color-background-danger": "rgba(96, 42, 40, 1)",
+ "--color-background-success": "rgba(27, 70, 20, 1)",
+ "--color-background-warning": "rgba(72, 58, 15, 1)",
+ "--color-background-disabled": "rgba(48, 48, 46, 0.5)",
+ "--color-text-primary": "rgba(250, 249, 245, 1)",
+ "--color-text-secondary": "rgba(194, 192, 182, 1)",
+ "--color-text-tertiary": "rgba(156, 154, 146, 1)",
+ "--color-text-inverse": "rgba(20, 20, 19, 1)",
+ "--color-text-ghost": "rgba(156, 154, 146, 0.5)",
+ "--color-text-info": "rgba(128, 170, 221, 1)",
+ "--color-text-danger": "rgba(238, 136, 132, 1)",
+ "--color-text-success": "rgba(122, 185, 72, 1)",
+ "--color-text-warning": "rgba(209, 160, 65, 1)",
+ "--color-text-disabled": "rgba(250, 249, 245, 0.5)",
+ "--color-border-primary": "rgba(222, 220, 209, 0.4)",
+ "--color-border-secondary": "rgba(222, 220, 209, 0.3)",
+ "--color-border-tertiary": "rgba(222, 220, 209, 0.15)",
+ "--color-border-inverse": "rgba(20, 20, 19, 0.15)",
+ "--color-border-ghost": "rgba(222, 220, 209, 0)",
+ "--color-border-info": "rgba(70, 130, 213, 1)",
+ "--color-border-danger": "rgba(205, 92, 88, 1)",
+ "--color-border-success": "rgba(89, 145, 48, 1)",
+ "--color-border-warning": "rgba(168, 120, 41, 1)",
+ "--color-border-disabled": "rgba(222, 220, 209, 0.1)",
+ "--color-ring-primary": "rgba(250, 249, 245, 0.7)",
+ "--color-ring-secondary": "rgba(194, 192, 182, 0.7)",
+ "--color-ring-inverse": "rgba(20, 20, 19, 0.7)",
+ "--color-ring-info": "rgba(128, 170, 221, 0.5)",
+ "--color-ring-danger": "rgba(205, 92, 88, 0.5)",
+ "--color-ring-success": "rgba(89, 145, 48, 0.5)",
+ "--color-ring-warning": "rgba(168, 120, 41, 0.5)",
+ }, CLAUDE_DESKTOP_SHARED),
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Host context helpers
+// ---------------------------------------------------------------------------
+
+function getControlValue(id) {
+ var el = document.getElementById(id);
+ return el ? el.value : null;
+}
+
+function buildHostContext(frame) {
+ var theme = getControlValue("phantom-theme") || "light";
+ var platform = getControlValue("phantom-platform") || "web";
+ var displayMode = getControlValue("phantom-display-mode") || "inline";
+ var clientPreset = getControlValue("phantom-client-preset") || "none";
+
+ var ctx = {
+ theme: theme,
+ platform: platform,
+ displayMode: displayMode,
+ availableDisplayModes: ["inline", "fullscreen", "pip"],
+ containerDimensions: { maxHeight: 6000 },
+ };
+
+ // Apply container width from the frame if available
+ if (frame) {
+ var w = frame.getBoundingClientRect().width;
+ if (w > 0) {
+ ctx.containerDimensions.width = Math.round(w);
+ }
+ }
+
+ // Always include styles in the context — even when empty — so the
+ // AppBridge's setHostContext diff detects preset transitions. If
+ // we omit the key, the bridge skips comparing it entirely and its
+ // internal _hostContext.styles gets stuck on the previous preset
+ // (third toggle wouldn't fire a host-context-changed notification).
+ var presetVars =
+ clientPreset !== "none" && CLIENT_PRESETS[clientPreset]
+ ? CLIENT_PRESETS[clientPreset][theme]
+ : null;
+ ctx.styles = { variables: presetVars || {} };
+
+ return ctx;
+}
+
+function applyHostTheme(theme) {
+ // The preview shell stays dark; only the simulated artboard reflects
+ // the host theme. We surface it as data-host-theme on the body so CSS
+ // can style chat bubbles, tool labels, and canvas-bg accordingly.
+ document.body.setAttribute("data-host-theme", theme);
+}
+
+// Track which CSS variables we've pushed into the app iframe so we can
+// strip them when the preset changes. The SDK's applyHostStyleVariables
+// only sets values (never removes them), so switching from a preset
+// back to "Default" would leave stale vars like --color-background-primary
+// hanging around and clobber the layout's own fallbacks.
+function clearHostStyleVariables(iframe, varNames) {
+ if (!iframe || !iframe.contentDocument || !varNames || !varNames.length) return;
+ var docEl = iframe.contentDocument.documentElement;
+ if (!docEl) return;
+ varNames.forEach(function (name) {
+ docEl.style.removeProperty(name);
+ });
+}
+
+function updateFrameBackground(frame, theme) {
+ if (!frame) return;
+ if (theme === "dark") {
+ frame.style.backgroundColor = "#2b2a27";
+ } else {
+ frame.style.backgroundColor = "#ffffff";
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Chat simulation
+// ---------------------------------------------------------------------------
+
+function buildChatContainer(containerEl, appName) {
+ // Chat wrapper — scrollable message list
+ var chat = document.createElement("div");
+ chat.className = "mcp-chat-container";
+ chat.id = "mcp-chat";
+
+ // User message bubble
+ var userMsg = document.createElement("div");
+ userMsg.className = "mcp-chat-message mcp-chat-user";
+ userMsg.innerHTML =
+ '
' +
+ "Show me the " + escapeHtml(appName) + " dashboard" +
+ "
";
+ chat.appendChild(userMsg);
+
+ // Assistant message bubble
+ var assistantMsg = document.createElement("div");
+ assistantMsg.className = "mcp-chat-message mcp-chat-assistant";
+ assistantMsg.innerHTML =
+ '' +
+ "Here's the " + escapeHtml(appName) + " dashboard for you:" +
+ "
";
+ chat.appendChild(assistantMsg);
+
+ // Tool result container (holds the iframe frame + handle)
+ var toolResult = document.createElement("div");
+ toolResult.className = "mcp-chat-tool-result";
+ toolResult.id = "mcp-tool-result";
+
+ var toolLabel = document.createElement("div");
+ toolLabel.className = "mcp-chat-tool-label";
+ toolLabel.textContent = "Tool Result";
+ toolResult.appendChild(toolLabel);
+
+ // Inner wrapper where frame + handle go
+ var toolBody = document.createElement("div");
+ toolBody.className = "mcp-chat-tool-body";
+ toolBody.id = "mcp-tool-body";
+ toolResult.appendChild(toolBody);
+
+ chat.appendChild(toolResult);
+
+ containerEl.appendChild(chat);
+ return { chat: chat, toolBody: toolBody, toolResult: toolResult };
+}
+
+function buildPipWindow(containerEl, appName) {
+ var pip = document.createElement("div");
+ pip.className = "mcp-pip-window";
+ pip.id = "mcp-pip-window";
+
+ var pipHeader = document.createElement("div");
+ pipHeader.className = "mcp-pip-header";
+ pipHeader.innerHTML =
+ '' + escapeHtml(appName) + "";
+ pip.appendChild(pipHeader);
+
+ var pipBody = document.createElement("div");
+ pipBody.className = "mcp-pip-body";
+ pipBody.id = "mcp-pip-body";
+ pip.appendChild(pipBody);
+
+ containerEl.appendChild(pip);
+
+ // External listeners that want to react when the pip moves or resizes.
+ // The frame iframe (which is overlaid on top of pip via position:fixed
+ // because moving the iframe in the DOM would reload it) hooks into
+ // this so it follows the pip.
+ var changeListeners = [];
+ function notifyChange() {
+ for (var i = 0; i < changeListeners.length; i++) changeListeners[i]();
+ }
+ function onChange(fn) { changeListeners.push(fn); }
+
+ // Make PiP draggable by header
+ var dragPip = null;
+ pipHeader.addEventListener("pointerdown", function (e) {
+ e.preventDefault();
+ var rect = pip.getBoundingClientRect();
+ var parentRect = containerEl.getBoundingClientRect();
+ dragPip = {
+ pointerId: e.pointerId,
+ startX: e.clientX,
+ startY: e.clientY,
+ startLeft: rect.left - parentRect.left,
+ startTop: rect.top - parentRect.top,
+ };
+ try { pipHeader.setPointerCapture(e.pointerId); } catch (_) {}
+ pipHeader.style.cursor = "grabbing";
+ });
+
+ function onPipMove(e) {
+ if (!dragPip) return;
+ var dx = e.clientX - dragPip.startX;
+ var dy = e.clientY - dragPip.startY;
+ pip.style.right = "auto";
+ pip.style.bottom = "auto";
+ pip.style.left = (dragPip.startLeft + dx) + "px";
+ pip.style.top = (dragPip.startTop + dy) + "px";
+ notifyChange();
+ }
+
+ function endPipDrag() {
+ if (!dragPip) return;
+ try { pipHeader.releasePointerCapture(dragPip.pointerId); } catch (_) {}
+ dragPip = null;
+ pipHeader.style.cursor = "";
+ }
+
+ window.addEventListener("pointermove", onPipMove);
+ window.addEventListener("pointerup", endPipDrag);
+ window.addEventListener("pointercancel", endPipDrag);
+
+ // The pip uses CSS `resize: both` for the corner resize affordance.
+ // ResizeObserver picks that up too, so frame-followers stay in sync.
+ if ("ResizeObserver" in window) {
+ new ResizeObserver(notifyChange).observe(pip);
+ }
+
+ return {
+ pip: pip,
+ pipBody: pipBody,
+ pipHeader: pipHeader,
+ onChange: onChange,
+ };
+}
+
+function escapeHtml(str) {
+ var div = document.createElement("div");
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+// ---------------------------------------------------------------------------
+// Display mode management
+// ---------------------------------------------------------------------------
+
+// Leave a vertical gap below the frame so the pip's CSS `resize: both`
+// corner affordance is grabbable. Otherwise the fixed-position frame
+// iframe captures all clicks over the bottom-right corner.
+var PIP_RESIZE_GUTTER = 14;
+
+function syncFrameToPip(frame, pipParts) {
+ if (frame.style.position !== "fixed" || frame.style.zIndex !== "21") return;
+ var rect = pipParts.pip.getBoundingClientRect();
+ var headerH = pipParts.pipHeader ? pipParts.pipHeader.offsetHeight : 32;
+ frame.style.top = (rect.top + headerH) + "px";
+ frame.style.left = rect.left + "px";
+ frame.style.width = rect.width + "px";
+ frame.style.height = Math.max(0, rect.height - headerH - PIP_RESIZE_GUTTER) + "px";
+}
+
+function applyDisplayMode(mode, els) {
+ var containerEl = els.containerEl;
+ var frame = els.frame;
+ var handle = els.handle;
+ var chatParts = els.chatParts;
+ var pipParts = els.pipParts;
+ var inlineHeight = els.inlineHeight || 0;
+
+ // The iframe NEVER moves in the DOM — browsers reload iframes when
+ // they're detached and re-attached, which loses the bridge
+ // connection. We use `visibility: hidden` (not `display: none`) on
+ // chrome elements when we need to hide them, so the frame's absolute
+ // positioning continues to render across modes.
+ chatParts.chat.style.visibility = "";
+ chatParts.chat.style.display = "";
+ chatParts.toolResult.style.display = "";
+ chatParts.toolResult.style.visibility = "";
+ pipParts.pip.style.display = "none";
+ handle.style.display = "";
+ frame.style.visibility = "";
+
+ // Reset layout-specific frame styles before applying the new mode
+ frame.style.position = "";
+ frame.style.top = "";
+ frame.style.left = "";
+ frame.style.right = "";
+ frame.style.bottom = "";
+ frame.style.zIndex = "";
+ frame.style.minHeight = "";
+
+ // Remove layout classes, re-add base
+ containerEl.className = "flex min-h-0 flex-1 overflow-hidden";
+
+ if (mode === "fullscreen") {
+ containerEl.classList.add("p-0");
+ containerEl.style.flexDirection = "row";
+ containerEl.style.position = "relative";
+ // Hide chat chrome but keep it in the render tree so the frame
+ // (which lives inside chat-tool-body) still renders.
+ chatParts.chat.style.visibility = "hidden";
+ handle.style.display = "none";
+ frame.style.visibility = "visible";
+ frame.style.position = "fixed";
+ var contRect = containerEl.getBoundingClientRect();
+ frame.style.top = contRect.top + "px";
+ frame.style.left = contRect.left + "px";
+ frame.style.width = contRect.width + "px";
+ frame.style.height = contRect.height + "px";
+ frame.style.minWidth = "";
+ frame.style.minHeight = "0";
+ frame.style.borderRadius = "0";
+ frame.style.border = "none";
+ frame.style.zIndex = "5";
+ } else if (mode === "pip") {
+ containerEl.classList.add("px-4", "canvas-bg");
+ containerEl.style.flexDirection = "column";
+ containerEl.style.position = "relative";
+ pipParts.pip.style.display = "flex";
+ chatParts.toolResult.style.visibility = "hidden";
+ handle.style.display = "none";
+ // Frame lives inside the chat subtree; we hide the chat's tool
+ // result with visibility:hidden which would inherit down. Force
+ // the frame visible explicitly so it renders as the pip overlay.
+ frame.style.visibility = "visible";
+ // Pin the frame fixed under the pip header. The actual coordinates
+ // are kept in sync with pip drag/resize via syncFrameToPip().
+ frame.style.position = "fixed";
+ frame.style.minWidth = "";
+ frame.style.minHeight = "0";
+ frame.style.borderRadius = "0 0 0.75rem 0.75rem";
+ frame.style.border = "none";
+ frame.style.zIndex = "21";
+ syncFrameToPip(frame, pipParts);
+ } else {
+ // inline — frame has its natural position inside chat tool body.
+ // Height is driven by ui/notifications/size-changed via onsizechange.
+ // Re-apply the last reported height so switching back from
+ // fullscreen/pip restores the right size without waiting for the
+ // app to resize itself.
+ containerEl.classList.add("px-4", "canvas-bg");
+ containerEl.style.flexDirection = "column";
+ frame.style.width = "calc(100% - 14px)";
+ frame.style.minWidth = "280px";
+ frame.style.minHeight = "";
+ frame.style.borderRadius = "";
+ frame.style.border = "";
+ if (inlineHeight > 0) {
+ var clamped = Math.max(200, Math.min(2400, inlineHeight));
+ frame.style.height = clamped + "px";
+ } else {
+ frame.style.height = "";
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Render
+// ---------------------------------------------------------------------------
+
+async function renderApp(containerEl, appHtml, appName, mcpEndpoint) {
+ // Build chat simulation
+ var chatParts = buildChatContainer(containerEl, appName);
+ var pipParts = buildPipWindow(containerEl, appName);
+
+ // Wrap the iframe in a resizable frame. Width follows the chat panel
+ // (the simulated chat container). Border color is theme-driven via
+ // data-host-theme on body so it matches light/dark canvases.
+ var frame = document.createElement("div");
+ frame.className = "mcp-app-frame shrink-0 rounded-lg shadow-lg relative";
+ frame.style.cssText = "width: 100%; min-width: 280px; background-color: #ffffff;";
+
+ // The resize handle lives at the container level — not inside the
+ // chat — because it's tied to the iframe's width regardless of
+ // display mode. It's positioned (fixed) next to the frame's right
+ // edge in inline mode and hidden in fullscreen/pip.
+ var handle = document.createElement("div");
+ handle.className = "mcp-app-frame-handle cursor-ew-resize rounded-sm touch-none select-none";
+ handle.setAttribute("role", "separator");
+ handle.setAttribute("aria-orientation", "vertical");
+ handle.setAttribute("aria-label", "Resize preview width");
+ handle.title = "Drag to resize";
+
+ // Frame goes inside the chat tool body. Handle goes at the container
+ // level so it isn't tied to the chat layout.
+ chatParts.toolBody.appendChild(frame);
+ containerEl.appendChild(handle);
+
+ // The handle floats next to the simulated chat panel (not the
+ // iframe) — it resizes the WHOLE chat (bubbles + tool result + frame
+ // inside), so the user simulates how the app looks at different chat
+ // panel widths. Position the handle just outside the chat's right
+ // edge.
+ function repositionHandle() {
+ if (handle.style.display === "none") return;
+ var rect = chatParts.chat.getBoundingClientRect();
+ handle.style.position = "fixed";
+ handle.style.top = rect.top + "px";
+ handle.style.left = (rect.right + 2) + "px";
+ handle.style.width = "12px";
+ handle.style.height = rect.height + "px";
+ handle.style.zIndex = "6";
+ }
+
+ // Live width readout in the toolbar + keep the handle aligned with
+ // the chat panel as the user resizes it (drag, mode switch, etc.).
+ var widthEl = document.getElementById("mcp-frame-width");
+ if ("ResizeObserver" in window) {
+ var ro = new ResizeObserver(function (entries) {
+ var w = Math.round(entries[0].contentRect.width);
+ if (widthEl) widthEl.textContent = w + "px";
+ repositionHandle();
+ });
+ ro.observe(chatParts.chat);
+ }
+ window.addEventListener("scroll", repositionHandle, { passive: true });
+ window.addEventListener("resize", repositionHandle);
+
+ // Pointer-capture drag — keeps tracking even when cursor enters the iframe
+ var dragState = null;
+ var resizeRaf = null;
+
+ function onPointerMove(e) {
+ if (!dragState) return;
+ var dx = e.clientX - dragState.startX;
+ // Resize the simulated chat panel (which contains the bubbles AND
+ // the iframe), not the iframe alone. The bound is the visible
+ // canvas area minus a small gutter for the handle itself.
+ var max = containerEl.clientWidth - 32;
+ var next = Math.max(320, Math.min(max, dragState.startWidth + dx));
+ chatParts.chat.style.width = next + "px";
+ chatParts.chat.style.maxWidth = next + "px";
+ repositionHandle();
+
+ // Debounced containerDimensions update via requestAnimationFrame
+ if (bridge && !resizeRaf) {
+ resizeRaf = requestAnimationFrame(function () {
+ resizeRaf = null;
+ bridge.setHostContext(buildHostContext(frame));
+ });
+ }
+ }
+
+ function endDrag() {
+ if (!dragState) return;
+ try { handle.releasePointerCapture(dragState.pointerId); } catch (_) {}
+ dragState = null;
+ handle.classList.remove("is-dragging");
+ document.body.classList.remove("mcp-resizing");
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", endDrag);
+ window.removeEventListener("pointercancel", endDrag);
+
+ // Send final dimensions after drag ends
+ if (bridge) {
+ bridge.setHostContext(buildHostContext(frame));
+ }
+ }
+
+ handle.addEventListener("pointerdown", function (e) {
+ e.preventDefault();
+ var rect = chatParts.chat.getBoundingClientRect();
+ chatParts.chat.style.width = rect.width + "px";
+ chatParts.chat.style.maxWidth = rect.width + "px";
+ dragState = {
+ pointerId: e.pointerId,
+ startX: e.clientX,
+ startWidth: rect.width,
+ };
+ try { handle.setPointerCapture(e.pointerId); } catch (_) {}
+ handle.classList.add("is-dragging");
+ document.body.classList.add("mcp-resizing");
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", endDrag);
+ window.addEventListener("pointercancel", endDrag);
+ });
+
+ handle.addEventListener("dblclick", function () {
+ chatParts.chat.style.width = "";
+ chatParts.chat.style.maxWidth = "";
+ requestAnimationFrame(function () {
+ repositionHandle();
+ if (bridge) bridge.setHostContext(buildHostContext(frame));
+ });
+ });
+
+ // Create the app iframe
+ var iframe = document.createElement("iframe");
+ iframe.className = "block w-full h-full border-0";
+ frame.appendChild(iframe);
+
+ // Connect to the MCP server if an endpoint is provided
+ var client = null;
+ if (mcpEndpoint) {
+ try {
+ client = new Client(IMPLEMENTATION, {
+ capabilities: { extensions: UI_EXTENSION_CAPABILITIES },
+ });
+ var url = new URL(mcpEndpoint, window.location.origin);
+ await client.connect(new StreamableHTTPClientTransport(url));
+ console.log("[Preview] MCP client connected:", client.getServerCapabilities());
+ } catch (e) {
+ console.warn("[Preview] Could not connect to MCP server:", e);
+ client = null;
+ }
+ }
+
+ // Build host capabilities from server capabilities
+ var capabilities = { openLinks: {}, logging: {} };
+ var serverCaps = client && client.getServerCapabilities && client.getServerCapabilities();
+ if (serverCaps && serverCaps.tools) capabilities.serverTools = {};
+ if (serverCaps && serverCaps.resources) capabilities.serverResources = {};
+ if (serverCaps && serverCaps.prompts) capabilities.serverPrompts = {};
+
+ // Build initial host context from controls
+ var initialContext = buildHostContext(frame);
+
+ // Create AppBridge (host side of the ext-apps protocol)
+ var bridge = new AppBridge(client, IMPLEMENTATION, capabilities, {
+ hostContext: initialContext,
+ });
+
+ bridge.onopenlink = async function (params) {
+ var u = params.url;
+ if (u.startsWith("https://") || u.startsWith("http://")) {
+ window.open(u, "_blank");
+ }
+ return { isError: false };
+ };
+
+ bridge.onmessage = async function (params) {
+ console.log("[Preview] App message:", params);
+ return { isError: false };
+ };
+
+ // Last reported content height — replayed when returning to inline
+ // (the app only sends size-changed when its own document resizes,
+ // so swapping modes won't trigger a fresh notification).
+ var lastReportedHeight = 0;
+
+ function applyContentHeight(h) {
+ if (typeof h !== "number" || h <= 0) return;
+ lastReportedHeight = h;
+ var mode = getControlValue("phantom-display-mode") || "inline";
+ if (mode !== "inline") return;
+ var clamped = Math.max(200, Math.min(2400, h));
+ frame.style.height = clamped + "px";
+ }
+
+ bridge.onsizechange = function (params) {
+ applyContentHeight(params && params.height);
+ };
+
+ bridge.onloggingmessage = function (params) {
+ console.log("[Preview] [" + params.level + "]:", params.data);
+ };
+
+ bridge.oninitialized = function () {
+ bridge.sendToolInput({ arguments: {} });
+ bridge.sendToolResult({
+ content: [{ type: "text", text: "Preview of " + appName }],
+ });
+ // Same-origin fallback for content-size auto-resize. The SDK's
+ // autoResize is supposed to emit ui/notifications/size-changed via
+ // setupSizeChangedNotifications, but in this preview's
+ // document.write-based architecture the initial firing is racy and
+ // the bridge's notification path doesn't reliably surface. Reading
+ // doc.body.scrollHeight directly is robust because the inner
+ // iframe is same-origin with the host.
+ var doc = iframe.contentDocument;
+ if (!doc || !doc.body) return;
+ var report = function () {
+ var h = Math.ceil(doc.body.scrollHeight || doc.body.offsetHeight || 0);
+ if (h > 0 && h !== lastReportedHeight) applyContentHeight(h);
+ };
+ report();
+ if ("ResizeObserver" in iframe.contentWindow) {
+ try {
+ var ro = new iframe.contentWindow.ResizeObserver(report);
+ ro.observe(doc.body);
+ } catch (_) {}
+ }
+ };
+
+ // Load the sandbox proxy into the iframe. The proxy is a minimal page
+ // that listens for sandbox-resource-ready, then document.write()s the
+ // app HTML into its OWN document (same-origin, so scripts work).
+ // This is the architecture @mcp-ui/client expects.
+ var sandboxUrl = containerEl.dataset.sandboxUrl;
+ iframe.src = sandboxUrl;
+
+ // Wait for the proxy to signal ready
+ await new Promise(function (resolve, reject) {
+ var timeout = setTimeout(function () {
+ reject(new Error("Sandbox proxy timed out"));
+ }, 10000);
+
+ function onMessage(event) {
+ if (event.source === iframe.contentWindow &&
+ event.data && event.data.method === "ui/notifications/sandbox-proxy-ready") {
+ clearTimeout(timeout);
+ window.removeEventListener("message", onMessage);
+ resolve();
+ }
+ }
+ window.addEventListener("message", onMessage);
+ });
+
+ // Connect bridge to the proxy iframe
+ await bridge.connect(
+ new PostMessageTransport(iframe.contentWindow)
+ );
+
+ // Send the app HTML to the proxy — it will document.write() it
+ // into its own document (same-origin, scripts execute properly)
+ bridge.sendSandboxResourceReady({ html: appHtml });
+
+ // Display mode elements for layout switching
+ var displayEls = {
+ containerEl: containerEl,
+ frame: frame,
+ handle: handle,
+ chatParts: chatParts,
+ pipParts: pipParts,
+ get inlineHeight() { return lastReportedHeight; },
+ };
+
+ // Keep the frame visually inside the pip when the pip is dragged or
+ // resized. The frame can't be a DOM child of pip — moving the iframe
+ // detaches it and triggers a reload — so we sync the fixed-position
+ // overlay coordinates instead.
+ pipParts.onChange(function () {
+ if (getControlValue("phantom-display-mode") === "pip") {
+ syncFrameToPip(frame, pipParts);
+ }
+ });
+
+ // Apply initial display mode and host theme
+ var initialMode = getControlValue("phantom-display-mode") || "inline";
+ applyDisplayMode(initialMode, displayEls);
+ repositionHandle();
+ applyHostTheme(initialContext.theme);
+ updateFrameBackground(frame, initialContext.theme);
+
+ // Attach change listeners to host context controls
+ var controlIds = [
+ "phantom-theme",
+ "phantom-platform",
+ "phantom-display-mode",
+ "phantom-client-preset",
+ ];
+
+ // Track which CSS variables the current preset pushed in, so we can
+ // strip them when the preset changes (the SDK's applyHostStyleVariables
+ // only sets values; it never removes them).
+ var appliedStyleVars = initialContext.styles && initialContext.styles.variables
+ ? Object.keys(initialContext.styles.variables)
+ : [];
+
+ controlIds.forEach(function (id) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.addEventListener("change", function () {
+ // Re-layout if display mode changed
+ if (id === "phantom-display-mode") {
+ applyDisplayMode(el.value, displayEls);
+ repositionHandle();
+ }
+
+ var ctx = buildHostContext(frame);
+
+ if (id === "phantom-theme" || id === "phantom-client-preset") {
+ // Clear previous preset's vars before applying the new context
+ // so swapping presets cleanly resets the cascade.
+ clearHostStyleVariables(iframe, appliedStyleVars);
+ appliedStyleVars = ctx.styles && ctx.styles.variables
+ ? Object.keys(ctx.styles.variables)
+ : [];
+ }
+
+ bridge.setHostContext(ctx);
+
+ // Update frame background and preview chat theme on theme changes
+ if (id === "phantom-theme" || id === "phantom-client-preset") {
+ updateFrameBackground(frame, ctx.theme);
+ applyHostTheme(ctx.theme);
+ }
+ });
+ });
+
+ return bridge;
+}
+
+// ---------------------------------------------------------------------------
+// Initialize
+// ---------------------------------------------------------------------------
+
+var container = document.getElementById("mcp-app-container");
+if (container) {
+ var appHtmlB64 = container.dataset.appHtml;
+ var appName = container.dataset.appName;
+ var mcpEndpoint = container.dataset.mcpEndpoint;
+ if (appHtmlB64) {
+ // atob() returns Latin-1; decode as UTF-8 to preserve non-ASCII chars
+ var appHtml = new TextDecoder().decode(
+ Uint8Array.from(atob(appHtmlB64), function (c) { return c.charCodeAt(0); })
+ );
+ renderApp(container, appHtml, appName, mcpEndpoint).catch(console.error);
+ }
+}
+
+window.renderMcpApp = renderApp;
diff --git a/bin/phantom-stdio b/bin/phantom-stdio
new file mode 100755
index 0000000..8eddf08
--- /dev/null
+++ b/bin/phantom-stdio
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+ESCRIPT="$PROJECT_DIR/phantom_mcp"
+cd "$PROJECT_DIR"
+MIX_ENV=stdio mise exec -- mix escript.build &>/dev/null /tmp/phantom-stdio.log
diff --git a/bin/release b/bin/release
index f6f435e..660ebd8 100755
--- a/bin/release
+++ b/bin/release
@@ -5,6 +5,11 @@ set -e
previous_version="${1}"
release_version="${2}"
+# Build production assets (priv/static/preview.{js,css}) — these ship in
+# the hex tarball but are gitignored, so rebuild fresh from source.
+npm ci
+npm run build
+
mix test
sed -i "" "s/$previous_version/$release_version/" README.md
diff --git a/guides/mcp_apps.md b/guides/mcp_apps.md
new file mode 100644
index 0000000..6e6cc79
--- /dev/null
+++ b/guides/mcp_apps.md
@@ -0,0 +1,305 @@
+# Building MCP Apps
+
+MCP Apps are interactive HTML interfaces delivered by your MCP server and
+rendered inside host applications (Claude Desktop, Cursor, etc.) as
+sandboxed iframes. The server provides the HTML; the host provides the
+sandbox, theming, and communication bridge.
+
+This guide walks through building an MCP App with Phantom, from the
+Elixir server side through the JavaScript client and into production.
+
+For the full client-side API reference, see the official
+[MCP Apps specification](https://apps.extensions.modelcontextprotocol.io/).
+
+## How it works
+
+```
+MCP Host (Claude Desktop, etc.)
+ |
+ |-- tools/list --> sees tool with _meta.ui.resourceUri
+ |-- tools/call --> invokes the tool, gets result
+ |-- resources/read --> fetches the app HTML
+ |
+ +-- renders HTML in sandboxed iframe
+ |
+ +-- App JS connects via postMessage
+ +-- receives tool input + result
+ +-- can call server tools, list resources
+```
+
+1. Your MCP router defines a tool with `app: MyApp`
+2. Phantom auto-registers a `ui://` resource for the app's HTML
+3. The host calls the tool, then fetches and renders the HTML
+4. The JavaScript in the HTML connects to the host via `postMessage`
+5. The app receives tool input/results and can call back to the server
+
+## Server side: defining the app
+
+### The App module
+
+An app module uses `Phantom.App` and implements `mount/2` and `render/1`.
+It works like a Plug pipeline — you can add plugs for layouts, CSP, etc.
+
+```elixir
+defmodule MyApp.MCP.WeatherApp 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: {MyApp.MCP.Layouts, :root}
+ plug :put_layout, html: {MyApp.MCP.Layouts, :app}
+
+ plug Phantom.App.CSP,
+ connect_domains: ["https://api.weather.gov"]
+
+ @impl Phantom.App
+ def mount(_params, session) do
+ {:ok, %{user: session.assigns[:user]}}
+ end
+
+ @impl Phantom.App
+ def render(assigns) do
+ ~H"""
+
+ """
+ end
+end
+```
+
+### Router registration
+
+Register the app on a tool with the `app:` option:
+
+```elixir
+defmodule MyApp.MCP.Router do
+ use Phantom.Router, name: "MyApp", vsn: "1.0"
+
+ @description "Show the weather dashboard"
+ tool :weather, app: MyApp.MCP.WeatherApp
+
+ def weather(%{"location" => location}, session) do
+ forecast = MyApp.Weather.fetch(location)
+ {:reply, Phantom.Tool.text(forecast), session}
+ end
+end
+```
+
+Phantom automatically creates a `ui:///weather` resource template that
+serves the app's rendered HTML when the host requests it.
+
+### Callbacks
+
+- **`mount(params, session)`** — called before render. Return
+ `{:ok, assigns}` to add data to the render assigns. `params` are the
+ tool arguments; `session` is the MCP session with any state from
+ `connect/2`.
+
+- **`render(assigns)`** — return HTML as a binary, iodata, or HEEx
+ template. The assigns include everything from `mount/2` plus
+ `:session`, `:params`, and `:conn`.
+
+## Client side: the JavaScript bridge
+
+The rendered HTML must include JavaScript from the
+[`@modelcontextprotocol/ext-apps`](https://www.npmjs.com/package/@modelcontextprotocol/ext-apps)
+package. This handles the `postMessage` protocol between your app
+and the host.
+
+### Install
+
+```bash
+npm install @modelcontextprotocol/ext-apps
+npm install --save-dev esbuild
+```
+
+### Entry point
+
+Create `assets/js/mcp_app.js`:
+
+```javascript
+import {
+ App,
+ applyDocumentTheme,
+ applyHostStyleVariables,
+ applyHostFonts,
+} from "@modelcontextprotocol/ext-apps";
+
+const app = new App({ name: "my-app", version: "1.0.0" });
+
+// Apply host theming when it changes
+app.onhostcontextchanged = (ctx) => {
+ if (ctx.theme) applyDocumentTheme(ctx.theme);
+ if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables);
+ if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts);
+};
+
+// Receive tool arguments from the host
+app.ontoolinput = ({ arguments: args }) => {
+ console.log("Tool input:", args);
+ // Update your UI with the tool arguments
+};
+
+// Receive tool execution result from the host
+app.ontoolresult = (result) => {
+ console.log("Tool result:", result);
+ // Update your UI with the result data
+};
+
+// Required: handle teardown when the host closes the app
+app.onteardown = async () => ({});
+app.onerror = console.error;
+
+// Connect to the host — must be called after handlers are set
+app.connect().then(() => {
+ const ctx = app.getHostContext();
+ if (ctx?.theme) applyDocumentTheme(ctx.theme);
+ console.log("Connected to host:", app.getHostVersion());
+});
+```
+
+The `App` class provides methods to call back to the MCP server
+through the host:
+
+```javascript
+// Call a server tool
+const result = await app.callServerTool({
+ name: "get_forecast",
+ arguments: { location: "NYC" }
+});
+
+// List server resources
+const { resources } = await app.listServerResources();
+
+// Read a server resource
+const { contents } = await app.readServerResource({
+ uri: "myapp:///data/123"
+});
+
+// Send a message to the host's conversation
+await app.sendMessage({
+ role: "user",
+ content: [{ type: "text", text: "Show me the weekly forecast" }]
+});
+```
+
+For the complete client API, see the
+[MCP Apps SDK documentation](https://apps.extensions.modelcontextprotocol.io/specification/architecture).
+
+For framework-specific starters (React, Vue, Svelte, Preact, Solid),
+see the [ext-apps examples](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples).
+
+### Bundle for production
+
+```bash
+npx esbuild assets/js/mcp_app.js \
+ --bundle --format=iife --minify \
+ --tree-shaking=true --target=es2020 \
+ --define:process.env.NODE_ENV=\"production\" \
+ --outfile=priv/static/mcp_app.js
+```
+
+For Phoenix projects, integrate with your existing esbuild pipeline in
+`config/config.exs` or add a mix alias.
+
+## Layout: loading the JavaScript
+
+The app HTML is delivered as a JSON string and injected into a sandboxed
+iframe by the host. The JavaScript bundle **must** be base64-encoded and
+loaded via a `data:` URI — inline `
+
+ {@inner_content}
+