From 7da1cc00c8b08f02aec75667739a80c934cb4cbd Mon Sep 17 00:00:00 2001 From: David Bernheisel Date: Fri, 24 Apr 2026 09:17:07 -0400 Subject: [PATCH] feat(apps): implement mcp-apps extension --- .github/workflows/ci.yml | 14 + .gitignore | 8 +- .mcp.json | 2 +- assets/css/preview.css | 273 +++ assets/js/preview.js | 834 +++++++ bin/phantom-stdio | 10 + bin/release | 5 + guides/mcp_apps.md | 305 +++ lib/phantom/app.ex | 356 +++ lib/phantom/app/csp.ex | 134 ++ lib/phantom/app/preview.ex | 382 ++++ lib/phantom/request.ex | 15 +- lib/phantom/resource_plug.ex | 16 +- lib/phantom/resource_template.ex | 28 +- lib/phantom/router.ex | 82 +- lib/phantom/session.ex | 6 +- lib/phantom/stdio.ex | 29 +- lib/phantom/tool.ex | 15 +- lib/phantom/ui.ex | 158 ++ mix.exs | 25 +- mix.lock | 5 +- package-lock.json | 2756 ++++++++++++++++++++++++ package.json | 41 + priv/static/preview.css | 2 + start.exs | 58 +- test/phantom/app_csp_test.exs | 128 ++ test/phantom/app_preview_test.exs | 171 ++ test/phantom/app_resource_test.exs | 86 + test/phantom/app_test.exs | 234 ++ test/phantom/tool_ui_test.exs | 95 + test/phantom/tool_visibility_test.exs | 54 + test/phantom/ui_capability_test.exs | 78 + test/phantom/ui_integration_test.exs | 67 + test/phantom/ui_resource_meta_test.exs | 101 + test/phantom/ui_test.exs | 178 ++ test/support/app/endpoint.ex | 6 + test/support/app/mcp/js/mcp_app.js | 127 ++ test/support/app/mcp/layouts.ex | 62 + test/support/app/mcp/minimal_app.ex | 21 + test/support/app/mcp/router.ex | 9 + test/support/app/mcp/sample_app.ex | 221 ++ test/support/app/router.ex | 17 + test/support/app/stdio.ex | 1 + test/support/cluster.ex | 8 +- 44 files changed, 7157 insertions(+), 66 deletions(-) create mode 100644 assets/css/preview.css create mode 100644 assets/js/preview.js create mode 100755 bin/phantom-stdio create mode 100644 guides/mcp_apps.md create mode 100644 lib/phantom/app.ex create mode 100644 lib/phantom/app/csp.ex create mode 100644 lib/phantom/app/preview.ex create mode 100644 lib/phantom/ui.ex create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 priv/static/preview.css create mode 100644 test/phantom/app_csp_test.exs create mode 100644 test/phantom/app_preview_test.exs create mode 100644 test/phantom/app_resource_test.exs create mode 100644 test/phantom/app_test.exs create mode 100644 test/phantom/tool_ui_test.exs create mode 100644 test/phantom/tool_visibility_test.exs create mode 100644 test/phantom/ui_capability_test.exs create mode 100644 test/phantom/ui_integration_test.exs create mode 100644 test/phantom/ui_resource_meta_test.exs create mode 100644 test/phantom/ui_test.exs create mode 100644 test/support/app/mcp/js/mcp_app.js create mode 100644 test/support/app/mcp/layouts.ex create mode 100644 test/support/app/mcp/minimal_app.ex create mode 100644 test/support/app/mcp/sample_app.ex 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""" +
+

Weather Dashboard

+
+
+ """ + 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} + + """ + end + + def app(assigns) do + ~H""" +
+ {@inner_content} +
+ """ + end +end +``` + +## Content Security Policy + +Use `Phantom.App.CSP` to declare which external domains your app needs +to contact. This sets the `Content-Security-Policy` header and provides +CSP metadata to the host for its sandbox configuration. + +```elixir +plug Phantom.App.CSP, + connect_domains: ["https://api.example.com", "wss://realtime.example.com"], + resource_domains: ["https://cdn.example.com"], + frame_domains: ["https://embed.example.com"] +``` + +See `Phantom.App.CSP` for all options. + +## Visibility + +Tools with an `app:` option control who can see and invoke them via the +`visibility` setting on the tool's `:ui` metadata: + +- `"model"` — visible in `tools/list`, the LLM can call it +- `"app"` — callable by other MCP App UIs via `app.callServerTool()` + +The default is `["model", "app"]`. Override with the `:ui` option: + +```elixir +# App-only: hidden from the model, callable from other apps +tool :fetch_data, app: MyApp.DataApp, + ui: [visibility: [:app]] + +# Model-only: the model can call it but other apps cannot +tool :admin, app: MyApp.AdminApp, + ui: [visibility: [:model]] +``` + +## Dev Preview + +Mount `Phantom.App.Preview` to browse and test your apps in the browser +during development: + +```elixir +# Phoenix Router +if Mix.env() == :dev do + forward "/mcp-apps", Phantom.App.Preview, + router: MyApp.MCP.Router, + mcp_endpoint: "/mcp" +end +``` + +The `:mcp_endpoint` option connects the preview to your running MCP +server, so interactive features (calling tools, listing resources) work +end-to-end. Visit `/mcp-apps` to see registered apps and click one to +open it in a sandboxed preview with a resizable viewport. + +See `Phantom.App.Preview` for details. diff --git a/lib/phantom/app.ex b/lib/phantom/app.ex new file mode 100644 index 0000000..4649cad --- /dev/null +++ b/lib/phantom/app.ex @@ -0,0 +1,356 @@ +defmodule Phantom.App do + @moduledoc """ + 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://apps.extensions.modelcontextprotocol.io/specification/architecture) + > 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\"\"\" +

Hello {@user.name}

+ \"\"\" + 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 ` + + {@inner_content} + + \"\"\" + 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](`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. + """ + + import Plug.Conn + + @typedoc """ + 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. + """ + @type rendered :: binary() | iodata() | struct() + + @callback mount(params :: map(), session :: Phantom.Session.t()) :: {:ok, map()} + @callback render(assigns :: map()) :: rendered() + + defmacro __using__(opts) do + permissions = Keyword.get(opts, :permissions) + domain = Keyword.get(opts, :domain) + prefers_border = Keyword.get(opts, :prefers_border) + + quote do + use Plug.Builder + @behaviour Phantom.App + + if unquote(permissions) do + plug :__phantom_put_permissions, unquote(permissions) + end + + if unquote(domain) do + plug :__phantom_put_domain, unquote(domain) + end + + if unquote(prefers_border) do + plug :__phantom_put_prefers_border, unquote(prefers_border) + end + + defp __phantom_put_permissions(conn, perms), + do: Phantom.App.put_permissions(conn, perms) + + defp __phantom_put_domain(conn, domain), + do: Phantom.App.put_domain(conn, domain) + + defp __phantom_put_prefers_border(conn, val), + do: Phantom.App.put_prefers_border(conn, val) + + @impl Phantom.App + def mount(_params, _session), do: {:ok, %{}} + defoverridable mount: 2 + + @doc false + def __phantom_app__(params, session) do + __phantom_app__(params, session, Plug.Test.conn(:get, "/")) + end + + @doc false + def __phantom_app__(params, session, conn) do + conn = + conn + |> Plug.Conn.put_private(:phantom_app, %{params: params, session: session}) + |> Plug.Conn.put_private(:phantom_ui_csp, Phantom.App.default_csp()) + |> __MODULE__.call(__MODULE__.init([])) + + ui_meta = Phantom.App.collect_ui_meta(conn) + + {:ok, extra_assigns} = mount(params, session) + + assigns = + extra_assigns + |> Map.put(:__changed__, nil) + |> Map.put(:session, session) + |> Map.put(:params, params) + |> Map.put(:conn, conn) + + html = + assigns + |> render() + |> Phantom.App.to_html() + |> Phantom.App.apply_layouts(conn) + + uri = if session.request, do: session.request.spec.uri_template + + content = + Phantom.Utils.remove_nils(%{ + text: html, + uri: uri, + mimeType: "text/html;profile=mcp-app" + }) + + {:reply, %{contents: [content], _meta: ui_meta}, session} + end + end + end + + @doc "Set sandbox permissions on the conn for `_meta.ui.permissions`." + @spec put_permissions(Plug.Conn.t(), [atom()]) :: Plug.Conn.t() + def put_permissions(conn, permissions) when is_list(permissions) do + put_private(conn, :phantom_ui_permissions, permissions) + end + + @doc "Set the sandbox domain on the conn for `_meta.ui.domain`." + @spec put_domain(Plug.Conn.t(), String.t()) :: Plug.Conn.t() + def put_domain(conn, domain) when is_binary(domain) do + put_private(conn, :phantom_ui_domain, domain) + end + + @doc "Set the border preference on the conn for `_meta.ui.prefersBorder`." + @spec put_prefers_border(Plug.Conn.t(), boolean()) :: Plug.Conn.t() + def put_prefers_border(conn, prefers_border) when is_boolean(prefers_border) do + put_private(conn, :phantom_ui_prefers_border, prefers_border) + end + + @doc """ + The restrictive default CSP per the MCP Apps spec. + + Applied automatically when no `Phantom.App.CSP` plug overrides it. + """ + def default_csp do + %{ + "default-src": "'none'", + "script-src": "'self' 'unsafe-inline'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data:", + "media-src": "'self' data:", + "connect-src": "'none'" + } + end + + @doc false + def collect_ui_meta(conn) do + csp = conn.private[:phantom_ui_csp] + permissions = conn.private[:phantom_ui_permissions] + domain = conn.private[:phantom_ui_domain] + prefers_border = conn.private[:phantom_ui_prefers_border] + + ui = + Phantom.Utils.remove_nils(%{ + csp: csp, + permissions: build_permissions(permissions), + domain: domain, + prefersBorder: prefers_border + }) + + %{ui: ui} + end + + defp build_permissions(nil), do: nil + defp build_permissions([]), do: nil + + defp build_permissions(perms) do + Map.new(perms, fn + :clipboard_write -> {:clipboardWrite, %{}} + perm -> {perm, %{}} + end) + end + + @phoenix_html_safe Code.ensure_loaded?(Phoenix.HTML.Safe) + + @doc """ + Convert render output to an HTML binary string. + """ + @spec to_html(binary() | iodata() | struct()) :: binary() + def to_html(content) when is_binary(content), do: content + + if @phoenix_html_safe do + def to_html(%_{} = struct) do + struct |> Phoenix.HTML.Safe.to_iodata() |> IO.iodata_to_binary() + end + else + def to_html(content), do: content + end + + def to_html(content), do: IO.iodata_to_binary(content) + + @doc false + @spec apply_layouts(binary(), Plug.Conn.t()) :: binary() + def apply_layouts(html, conn) do + if Code.ensure_loaded?(Phoenix.Controller) do + html + |> apply_layout(Phoenix.Controller.layout(conn, "html")) + |> apply_layout(Phoenix.Controller.root_layout(conn, "html")) + else + html + end + end + + defp apply_layout(html, false), do: html + + defp apply_layout(html, {module, template}) when is_atom(module) and is_atom(template) do + assigns = %{inner_content: {:safe, html}, __changed__: nil} + apply(module, template, [assigns]) |> to_html() + end +end diff --git a/lib/phantom/app/csp.ex b/lib/phantom/app/csp.ex new file mode 100644 index 0000000..bbd0228 --- /dev/null +++ b/lib/phantom/app/csp.ex @@ -0,0 +1,134 @@ +defmodule Phantom.App.CSP do + @moduledoc """ + Content Security Policy plug for MCP App resources. + + When used inside a `Phantom.App` module, this plug: + 1. Sets the `Content-Security-Policy` response header + 2. Stores UI metadata in `conn.private[:phantom_ui]` for the MCP `_meta.ui` response + + ## Usage in a Phantom.App module + + defmodule MyApp.DashboardApp do + use Phantom.App, + permissions: [:clipboard_write], + prefers_border: true + + plug Phantom.App.CSP, + connect_domains: ["https://api.example.com"], + resource_domains: ["https://cdn.example.com"], + + def mount(assigns), do: ... + def render(assigns), do: ... + end + + ## Usage in a Phoenix pipeline + + import Phantom.App.CSP + + pipeline :mcp_apps do + plug :put_content_security_policy, + connect_domains: ["https://api.example.com"] + end + + ## Options + + * `:connect_domains` - Origins allowed for fetch/XHR/WebSocket + * `:resource_domains` - Origins for scripts, images, styles, fonts, media + * `:frame_domains` - Origins for nested iframes + * `:base_uri_domains` - Origins for document base-uri + * `:permissions` - Sandbox permissions (`:camera`, `:microphone`, `:geolocation`, `:clipboard_write`) + * `:domain` - Dedicated sandbox origin hint + * `:prefers_border` - Whether the app prefers a visible border + """ + + import Plug.Conn + + @behaviour Plug + + @impl Plug + def init(opts), do: opts + + @impl Plug + def call(conn, opts) do + conn + |> put_content_security_policy(opts) + |> put_private(:phantom_ui_csp, csp_meta(opts)) + end + + @doc """ + Set the `Content-Security-Policy` response header. + + conn + |> put_content_security_policy( + connect_domains: ["https://api.example.com"], + resource_domains: ["https://cdn.example.com"] + ) + """ + @spec put_content_security_policy(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() + def put_content_security_policy(conn, opts) do + put_resp_header(conn, "content-security-policy", build(opts)) + end + + @doc """ + Build a CSP header string from domain options. + + Returns a policy string following the MCP Apps spec defaults. + """ + @spec build(Keyword.t()) :: String.t() + def build(opts) when is_list(opts) do + connect = Keyword.get(opts, :connect_domains, []) + resource = Keyword.get(opts, :resource_domains, []) + frame = Keyword.get(opts, :frame_domains, []) + base_uri = Keyword.get(opts, :base_uri_domains, []) + + directives = [ + {"default-src", ["'none'"]}, + {"script-src", ["'self'", "'unsafe-inline'"] ++ resource}, + {"style-src", ["'self'", "'unsafe-inline'"] ++ resource}, + {"img-src", ["'self'", "data:"] ++ resource}, + {"font-src", if(resource != [], do: ["'self'"] ++ resource, else: nil)}, + {"media-src", ["'self'", "data:"] ++ resource}, + {"connect-src", if(connect != [], do: ["'self'"] ++ connect, else: ["'none'"])}, + {"frame-src", if(frame != [], do: frame, else: nil)}, + {"base-uri", if(base_uri != [], do: base_uri, else: nil)} + ] + + directives + |> Enum.reject(fn {_name, sources} -> is_nil(sources) end) + |> Enum.map_join("; ", fn {name, sources} -> + "#{name} #{Enum.join(sources, " ")}" + end) + end + + @doc """ + Build a CSP header string from a `Phantom.UI` struct. + """ + @spec build_from_ui(Phantom.UI.t() | nil) :: String.t() + def build_from_ui(nil), do: build([]) + + def build_from_ui(%Phantom.UI{} = ui) do + build( + connect_domains: ui.connect_domains || [], + resource_domains: ui.resource_domains || [], + frame_domains: ui.frame_domains || [], + base_uri_domains: ui.base_uri_domains || [] + ) + end + + @csp_keys ~w[connect_domains resource_domains frame_domains base_uri_domains]a + + defp csp_meta(opts) do + csp_opts = Keyword.take(opts, @csp_keys) + + if csp_opts == [] do + nil + else + Phantom.Utils.remove_nils(%{ + connectDomains: csp_opts[:connect_domains], + resourceDomains: csp_opts[:resource_domains], + frameDomains: csp_opts[:frame_domains], + baseUriDomains: csp_opts[:base_uri_domains] + }) + end + end +end diff --git a/lib/phantom/app/preview.ex b/lib/phantom/app/preview.ex new file mode 100644 index 0000000..e153061 --- /dev/null +++ b/lib/phantom/app/preview.ex @@ -0,0 +1,382 @@ +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 || "" + + """ +
  • + +
    + #{t.name} + +
    +

    #{desc}

    + #{if uri != "", do: ~s(#{uri}), else: ""} +
    +
  • + """ + end) + + body = + if templates == [] do + """ +
    +
    👻
    +

    No app resources registered

    +

    Define a resource with scheme: "ui" in your MCP router.

    +
    + """ + else + count = length(templates) + label = if count == 1, do: "app", else: "apps" + + """ +

    #{count} #{label} registered

    + + """ + end + + """ + + + + + + Phantom · App Preview + + + +
    +
    +
    +
    👻
    + Phantom MCP +
    +

    App Preview

    +
    + #{body} +
    + Development preview — do not expose in production +
    +
    + + + """ + end + + defp render_frame(name, app_html, conn, mcp_endpoint) do + assets = assets_path(conn) + back_url = base_path(conn) + app_html_b64 = Base.encode64(app_html) + + endpoint_attr = + if mcp_endpoint, do: ~s( data-mcp-endpoint="#{mcp_endpoint}"), else: "" + + """ + + + + + + Phantom · #{name} + + + + +
    + + + + +
    +
    + + + + """ + end +end diff --git a/lib/phantom/request.ex b/lib/phantom/request.ex index 2144d88..298f945 100644 --- a/lib/phantom/request.ex +++ b/lib/phantom/request.ex @@ -195,9 +195,22 @@ defmodule Phantom.Request do end def resource_response({:reply, results, %Session{} = session}, _uri, _session) do - {:reply, Phantom.Resource.response(results), session} + response = Phantom.Resource.response(results) + {:reply, maybe_add_ui_meta(response, session.request), session} end + # Dynamic _meta from plug pipeline takes precedence + defp maybe_add_ui_meta(%{_meta: _} = response, _request), do: response + + defp maybe_add_ui_meta(response, %{spec: %{meta: %{ui: %Phantom.UI{} = ui}}}) do + case Phantom.UI.to_resource_meta(ui) do + nil -> response + meta -> Map.put(response, :_meta, meta) + end + end + + defp maybe_add_ui_meta(response, _), do: response + @doc "Resource updated notification" def resource_updated(content) do %{jsonrpc: "2.0", method: "notifications/resources/updated", params: content} diff --git a/lib/phantom/resource_plug.ex b/lib/phantom/resource_plug.ex index f16233e..950ca73 100644 --- a/lib/phantom/resource_plug.ex +++ b/lib/phantom/resource_plug.ex @@ -18,13 +18,19 @@ defmodule Phantom.ResourcePlug do | request: %{fake_conn.assigns.session.request | spec: fake_conn.assigns.resource_template} } + handler = fake_conn.assigns.resource_template.handler + function = fake_conn.assigns.resource_template.function + + args = + if function_exported?(handler, function, 3) do + [fake_conn.path_params, session, fake_conn] + else + [fake_conn.path_params, session] + end + result = try do - apply( - fake_conn.assigns.resource_template.handler, - fake_conn.assigns.resource_template.function, - [fake_conn.path_params, session] - ) + apply(handler, function, args) rescue _e in FunctionClauseError -> {:error, Phantom.Request.resource_not_found(%{uri: fake_conn.assigns.uri}), diff --git a/lib/phantom/resource_template.ex b/lib/phantom/resource_template.ex index 76fd9e9..5af6788 100644 --- a/lib/phantom/resource_template.ex +++ b/lib/phantom/resource_template.ex @@ -122,14 +122,26 @@ defmodule Phantom.ResourceTemplate do Represent a ResourceTemplate spec as json when listing the available resources to clients. """ def to_json(%__MODULE__{} = resource) do - remove_nils(%{ - uriTemplate: resource.uri_template, - name: resource.name, - size: resource.size, - description: resource.description, - mimeType: resource.mime_type, - icons: Phantom.Icon.to_json_list(resource.icons) - }) + base = + remove_nils(%{ + uriTemplate: resource.uri_template, + name: resource.name, + size: resource.size, + description: resource.description, + mimeType: resource.mime_type, + icons: Phantom.Icon.to_json_list(resource.icons) + }) + + case resource.meta do + %{ui: %Phantom.UI{} = ui} -> + case Phantom.UI.to_resource_meta(ui) do + nil -> base + meta -> Map.put(base, :_meta, meta) + end + + _ -> + base + end end defp to_uri_6570(str) do diff --git a/lib/phantom/router.ex b/lib/phantom/router.ex index 3f560d7..58265e7 100644 --- a/lib/phantom/router.ex +++ b/lib/phantom/router.ex @@ -349,7 +349,10 @@ defmodule Phantom.Router do client_capabilities = %{ roots: params["capabilities"]["roots"], sampling: params["capabilities"]["sampling"], - elicitation: params["capabilities"]["elicitation"] + elicitation: params["capabilities"]["elicitation"], + ui: + get_in(params, ["capabilities", "extensions", "io.modelcontextprotocol/ui"]) || + false } session = %{ @@ -373,7 +376,8 @@ defmodule Phantom.Router do |> Phantom.Router.prompt_capability(__MODULE__, session) |> Phantom.Router.resource_capability(__MODULE__, session) |> Phantom.Router.completion_capability(__MODULE__, session) - |> Phantom.Router.logging_capability(__MODULE__, session), + |> Phantom.Router.logging_capability(__MODULE__, session) + |> Phantom.Router.ui_capability(__MODULE__, session), serverInfo: server_info, instructions: instructions }, session} @@ -582,6 +586,7 @@ defmodule Phantom.Router do # tool/4: handler + opts + do block defmacro tool(name, handler, opts, [{:do, block}]) when is_list(opts) do {defs, fields} = JSONSchema.transform_block(block) + {opts, app_ast} = maybe_register_app(name, opts, __CALLER__) meta = %{line: __CALLER__.line, file: __CALLER__.file} quote line: meta.line, file: meta.file, generated: true do @@ -603,6 +608,8 @@ defmodule Phantom.Router do opts ) ) + + unquote(app_ast) end end @@ -611,6 +618,7 @@ defmodule Phantom.Router do defmacro tool(name, opts, [{:do, block}]) when is_list(opts) do {defs, fields} = JSONSchema.transform_block(block) handler = __CALLER__.module + {opts, app_ast} = maybe_register_app(name, opts, __CALLER__) meta = %{line: __CALLER__.line, file: __CALLER__.file} quote line: meta.line, file: meta.file, generated: true do @@ -632,10 +640,13 @@ defmodule Phantom.Router do opts ) ) + + unquote(app_ast) end end defmacro tool(name, handler, opts) when is_list(opts) do + {opts, app_ast} = maybe_register_app(name, opts, __CALLER__) meta = %{line: __CALLER__.line, file: __CALLER__.file} quote line: meta.line, file: meta.file, generated: true do @@ -653,6 +664,8 @@ defmodule Phantom.Router do opts ) ) + + unquote(app_ast) end end @@ -676,6 +689,7 @@ defmodule Phantom.Router do raise "must provide a module or function handler" end + {opts, app_ast} = maybe_register_app(name, opts, __CALLER__) meta = %{line: __CALLER__.line, file: __CALLER__.file} quote line: meta.line, file: meta.file, generated: true do @@ -693,6 +707,8 @@ defmodule Phantom.Router do opts ) ) + + unquote(app_ast) end end @@ -726,6 +742,9 @@ defmodule Phantom.Router do scheme = case URI.new(pattern) do + {:ok, %{scheme: "ui"}} -> + raise "The ui:// scheme is reserved for MCP Apps" + {:ok, %{scheme: scheme, path: path}} when is_binary(scheme) and is_binary(path) -> scheme @@ -764,6 +783,45 @@ defmodule Phantom.Router do end end + @doc false + defp maybe_register_app(name, opts, caller) do + {app_module, opts} = Keyword.pop(opts, :app) + + if app_module do + app_module = Macro.expand(app_module, caller) + uri = "ui:///#{name}" + + # Auto-set ui.resource_uri if not already provided + opts = + Keyword.update(opts, :ui, [resource_uri: uri], fn ui_opts -> + Keyword.put_new(ui_opts, :resource_uri, uri) + end) + + resource_router = Module.concat([caller.module, ResourceRouter, "Ui"]) + meta = %{line: caller.line, file: caller.file} + + app_ast = + quote line: meta.line, file: meta.file, generated: true do + # description was already read by the tool macro into `description` var + @phantom_resource_templates Phantom.ResourceTemplate.build( + uri: unquote(uri), + router: unquote(resource_router), + handler: unquote(app_module), + function: :__phantom_app__, + name: to_string(unquote(name)), + description: description, + scheme: "ui", + mime_type: "text/html;profile=mcp-app", + meta: unquote(Macro.escape(meta)) + ) + end + + {opts, app_ast} + else + {opts, nil} + end + end + @doc """ Define a prompt that can be retrieved by the MCP client. @@ -1021,6 +1079,25 @@ defmodule Phantom.Router do Map.put(capabilities, :logging, %{}) end + @doc false + def ui_capability(capabilities, router, session) do + resource_templates = Cache.list(session, router, :resource_templates) + + if Enum.any?(resource_templates, &(&1.scheme == "ui")) do + extensions = Map.get(capabilities, :extensions, %{}) + + Map.put( + capabilities, + :extensions, + Map.put(extensions, "io.modelcontextprotocol/ui", %{ + mimeTypes: ["text/html;profile=mcp-app"] + }) + ) + else + capabilities + end + end + @doc false def completion_capability(capabilities, router, session) do resource_templates = Cache.list(session, router, :resource_templates) @@ -1190,6 +1267,7 @@ defmodule Phantom.Router do {page, next_cursor} = session |> Cache.list(router, :tools) + |> Enum.filter(&Phantom.UI.model_visible?/1) |> paginate(cursor, &Tool.to_json/1) {:reply, Map.merge(%{tools: page}, next_cursor || %{}), session} diff --git a/lib/phantom/session.ex b/lib/phantom/session.ex index 4fedf6f..72c9cee 100644 --- a/lib/phantom/session.ex +++ b/lib/phantom/session.ex @@ -27,7 +27,8 @@ defmodule Phantom.Session do client_capabilities: %{ roots: false, sampling: false, - elicitation: false + elicitation: false, + ui: false }, close_after_complete: true, requests: %{} @@ -55,7 +56,8 @@ defmodule Phantom.Session do client_capabilities: %{ elicitation: false | map(), sampling: false | map(), - roots: false | map() + roots: false | map(), + ui: false | map() }, transport_pid: pid() } diff --git a/lib/phantom/stdio.ex b/lib/phantom/stdio.ex index cd5ef54..e1f58d8 100644 --- a/lib/phantom/stdio.ex +++ b/lib/phantom/stdio.ex @@ -107,8 +107,15 @@ defmodule Phantom.Stdio do - ### Claude Desktop + ### Claude + **Claude Code** + + ```bash + claude mcp add my_app -e PATH=/path/to/elixir/bin:/path/to/erlang/bin:/usr/local/bin:/usr/bin:/bin -- /path/to/my_app + ``` + + **Claude Desktop** Find your `claude_desktop_config.json`: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` @@ -285,7 +292,7 @@ defmodule Phantom.Stdio do "params" => Phantom.Elicit.to_json(elicitation) }) - IO.write(output, JSON.encode!(Phantom.Request.to_json(request)) <> "\n") + IO.write(output, [encode_ascii(Phantom.Request.to_json(request)), ?\n]) await_elicitation(request.id, timeout) end end @@ -316,15 +323,29 @@ defmodule Phantom.Stdio do state state, _id, _event, payload when is_map(payload) and map_size(payload) == 0 -> - IO.write(output, JSON.encode!(%{jsonrpc: "2.0", result: %{}}) <> "\n") + IO.write(output, [encode_ascii(%{jsonrpc: "2.0", result: %{}}), ?\n]) state state, _id, _event, %{} = payload -> - IO.write(output, JSON.encode!(payload) <> "\n") + IO.write(output, [encode_ascii(payload), ?\n]) state state, _id, _event, _payload -> state end end + + # Encode JSON with non-ASCII characters escaped as \uXXXX sequences. + # Stdio clients (Claude Desktop, Codex) may not accept raw UTF-8 in + # JSON strings even though RFC 8259 permits it. Using + # :json.encode_binary_escape_all/1 ensures only ASCII bytes are emitted. + defp encode_ascii(data) do + JSON.encode_to_iodata!(data, &ascii_encoder/2) + end + + defp ascii_encoder(str, _encoder) when is_binary(str), + do: :json.encode_binary_escape_all(str) + + defp ascii_encoder(other, encoder), + do: JSON.Encoder.encode(other, encoder) end diff --git a/lib/phantom/tool.ex b/lib/phantom/tool.ex index 4279933..1278b71 100644 --- a/lib/phantom/tool.ex +++ b/lib/phantom/tool.ex @@ -49,6 +49,7 @@ defmodule Phantom.Tool do :input_schema, :annotations, :icons, + :ui, meta: %{} ] @@ -62,7 +63,8 @@ defmodule Phantom.Tool do input_schema: JSONSchema.t() | nil, output_schema: JSONSchema.t() | nil, annotations: Annotation.t(), - icons: [Phantom.Icon.t()] | nil + icons: [Phantom.Icon.t()] | nil, + ui: Phantom.UI.t() | nil } @type json :: %{ @@ -168,6 +170,8 @@ defmodule Phantom.Tool do {annotation_attrs, attrs} = Map.split(attrs, ~w[title idempotent destructive read_only open_world]a) + {ui_attrs, attrs} = Map.pop(attrs, :ui) + attrs = Map.put(attrs, :name, attrs[:name] || to_string(attrs[:function])) @@ -178,13 +182,17 @@ defmodule Phantom.Tool do | annotations: Annotation.build(annotation_attrs), input_schema: JSONSchema.build(attrs[:input_schema]), output_schema: JSONSchema.build(attrs[:output_schema]), - icons: build_icons(attrs[:icons]) + icons: build_icons(attrs[:icons]), + ui: build_ui(ui_attrs) } end defp build_icons(nil), do: nil defp build_icons(icons) when is_list(icons), do: Enum.map(icons, &Phantom.Icon.build/1) + defp build_ui(nil), do: nil + defp build_ui(attrs), do: Phantom.UI.build(attrs) + @doc """ Represent a Tool spec as json when listing the available tools to clients. """ @@ -195,7 +203,8 @@ defmodule Phantom.Tool do inputSchema: JSONSchema.to_json(tool.input_schema), outputSchema: if(tool.output_schema, do: JSONSchema.to_json(tool.output_schema)), annotations: Annotation.to_json(tool.annotations), - icons: Phantom.Icon.to_json_list(tool.icons) + icons: Phantom.Icon.to_json_list(tool.icons), + _meta: Phantom.UI.to_tool_meta(tool.ui) }) end diff --git a/lib/phantom/ui.ex b/lib/phantom/ui.ex new file mode 100644 index 0000000..f296e96 --- /dev/null +++ b/lib/phantom/ui.ex @@ -0,0 +1,158 @@ +defmodule Phantom.UI do + @moduledoc """ + Metadata for the MCP Apps extension (`io.modelcontextprotocol/ui`). + + MCP Apps allow servers to deliver interactive HTML user interfaces + that render inside MCP hosts as sandboxed iframes. This module + encapsulates the UI metadata for both tools (linking to a UI resource) + and resources (CSP, permissions, sandbox configuration). + + See https://apps.extensions.modelcontextprotocol.io/ + """ + + import Phantom.Utils + + @ui_keys ~w[resource_uri visibility connect_domains resource_domains frame_domains + base_uri_domains permissions domain prefers_border]a + + @valid_visibility ~w[model app]a + + defstruct [ + :resource_uri, + :connect_domains, + :resource_domains, + :frame_domains, + :base_uri_domains, + :permissions, + :domain, + :prefers_border, + visibility: [:model, :app] + ] + + @type visibility :: :model | :app + + @type t :: %__MODULE__{ + resource_uri: String.t() | nil, + connect_domains: [String.t()] | nil, + resource_domains: [String.t()] | nil, + frame_domains: [String.t()] | nil, + base_uri_domains: [String.t()] | nil, + permissions: [atom()] | nil, + domain: String.t() | nil, + prefers_border: boolean() | nil, + visibility: [visibility()] + } + + @doc """ + Build a `%Phantom.UI{}` from a keyword list or map. + + Returns `nil` if no UI-related attributes are present. + + Raises `ArgumentError` if `visibility` contains unknown values. + Valid visibility values are `:model` and `:app`. + """ + @spec build(Keyword.t() | map()) :: t() | nil + def build(attrs) when is_list(attrs), do: build(Map.new(attrs)) + + def build(attrs) when is_map(attrs) do + ui_attrs = Map.take(attrs, @ui_keys) + + if map_size(ui_attrs) == 0 do + nil + else + ui_attrs + |> validate_visibility() + |> then(&struct!(__MODULE__, &1)) + end + end + + defp validate_visibility(%{visibility: vis} = attrs) when is_list(vis) do + normalized = Enum.map(vis, &to_visibility_atom/1) + + case normalized -- @valid_visibility do + [] -> + %{attrs | visibility: normalized} + + invalid -> + raise ArgumentError, + "invalid visibility values: #{inspect(invalid)}. " <> + "Expected a list of #{inspect(@valid_visibility)}" + end + end + + defp validate_visibility(attrs), do: attrs + + defp to_visibility_atom(val) when val in @valid_visibility, do: val + defp to_visibility_atom(val) when is_binary(val), do: String.to_existing_atom(val) + defp to_visibility_atom(val), do: val + + @doc """ + Produce the `_meta` map for a tool's JSON representation. + + Returns `nil` when no UI is configured, which gets stripped by `remove_nils`. + Visibility atoms are serialized to strings for the JSON wire format. + """ + @spec to_tool_meta(t() | nil) :: %{ui: map()} | nil + def to_tool_meta(nil), do: nil + + def to_tool_meta(%__MODULE__{} = ui) do + %{ + ui: + remove_nils(%{ + resourceUri: ui.resource_uri, + visibility: Enum.map(ui.visibility, &to_string/1) + }) + } + end + + @doc """ + Produce the `_meta` map for a resource's JSON representation. + + Includes CSP domains, permissions, domain, and border preference. + Returns `nil` when no resource-side metadata is present. + """ + @spec to_resource_meta(t() | nil) :: %{ui: map()} | nil + def to_resource_meta(nil), do: nil + + def to_resource_meta(%__MODULE__{} = ui) do + csp = + remove_nils(%{ + connectDomains: ui.connect_domains, + resourceDomains: ui.resource_domains, + frameDomains: ui.frame_domains, + baseUriDomains: ui.base_uri_domains + }) + + permissions = build_permissions(ui.permissions) + + meta = + remove_nils(%{ + csp: if(map_size(csp) > 0, do: csp), + permissions: permissions, + domain: ui.domain, + prefersBorder: ui.prefers_border + }) + + if map_size(meta) == 0, do: nil, else: %{ui: meta} + end + + @doc """ + Returns `true` if the tool should appear in `tools/list` (visible to the model). + + Tools without UI metadata are always visible. Tools with UI are visible + when their visibility list includes `:model`. + """ + @spec model_visible?(Phantom.Tool.t()) :: boolean() + def model_visible?(%Phantom.Tool{ui: nil}), do: true + def model_visible?(%Phantom.Tool{ui: %__MODULE__{visibility: vis}}), do: :model in vis + + defp build_permissions(nil), do: nil + defp build_permissions([]), do: nil + + defp build_permissions(perms) when is_list(perms) do + Map.new(perms, fn + :clipboard_write -> {:clipboardWrite, %{}} + perm -> {perm, %{}} + end) + end +end diff --git a/mix.exs b/mix.exs index 3aa2a9d..613d6ce 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,6 @@ defmodule Phantom.MixProject do def project do [ - aliases: aliases(), compilers: Mix.compilers(), app: :phantom_mcp, description: "Elixir MCP (Model Context Protocol) server library with Plug", @@ -32,7 +31,9 @@ defmodule Phantom.MixProject do end defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(:stdio), do: ["lib", "test/support/app/mcp", "test/support/app/stdio.ex"] + + defp elixirc_paths(:stdio), do: ["lib", "test/support/app"] + defp elixirc_paths(_), do: ["lib"] defp escript(:stdio), do: [main_module: Test.Stdio, app: nil] @@ -42,15 +43,16 @@ defmodule Phantom.MixProject do [ {:plug, "~> 1.0"}, {:telemetry, "~> 1.0"}, - {:phoenix_pubsub, "~> 2.0", optional: true, only: [:dev, :test, :prod]}, + {:phoenix_pubsub, "~> 2.0", optional: true, only: [:dev, :test, :prod, :stdio]}, {:uuidv7, "~> 1.0"}, ## Test - {:phoenix, "~> 1.7", only: [:test]}, + {:phoenix_live_view, "~> 1.0", only: [:dev, :test, :stdio]}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.31", only: :dev, warn_if_outdated: true, runtime: false}, - {:tidewave, "~> 0.5", only: [:test], warn_if_outdated: true}, - {:exsync, "~> 0.4", only: [:dev]}, - {:bandit, "~> 1.0", only: [:test]} + {:tidewave, "~> 0.5", only: [:dev, :test], warn_if_outdated: true}, + {:makeup_javascript, "~> 0.1", only: :dev}, + {:phoenix_live_reload, "~> 1.5", only: [:dev, :test, :stdio]}, + {:bandit, "~> 1.0", only: [:dev, :test]} ] end @@ -100,15 +102,8 @@ defmodule Phantom.MixProject do defp docs do [ main: "Phantom", - extras: ~w[CHANGELOG.md], + extras: ["guides/mcp_apps.md", "CHANGELOG.md"], before_closing_body_tag: %{html: @mermaidjs} ] end - - defp aliases do - [ - tidewave: - "run --no-halt -e 'Agent.start(fn -> Bandit.start_link(plug: Tidewave, port: 4000) end)'" - ] - end end diff --git a/mix.lock b/mix.lock index 98c70a7..e3df881 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,6 @@ "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, - "exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, @@ -16,12 +15,16 @@ "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "makeup_javascript": {:hex, :makeup_javascript, "0.1.0", "2540dac055b36cb3211a4fc6a27e1980906f334651cda306b63f182142cbfcea", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "90a985a6e46cfbb42b751db123dc8a7993b3736c1e2395e5cbb1fcba83ca09c6"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9338f18 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2756 @@ +{ + "name": "phantom_mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phantom_mcp", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@mcp-ui/client": "^7.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.2.4", + "esbuild": "^0.28.0", + "tailwindcss": "^4.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mcp-ui/client": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-7.0.0.tgz", + "integrity": "sha512-by2YqknAegCbOIjQzEVAjTjopfrbwc0DYycWpAt33qVGv5yx8pChPVmO4l5iILrpVnRUPAtmhguPSoTTL7w0qw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.2.0", + "@modelcontextprotocol/sdk": "^1.27.1", + "zod": "^3.23.8" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, + "node_modules/@mcp-ui/client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.0.tgz", + "integrity": "sha512-gs8rYVx6a8pyCvSpXq7TyVLTERCC94JLrcmJgBs0+3p4jp3iQdJPu1IU+2ovVdFZ1sW8JgmvTkRnxAlIizKINg==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tailwindcss/cli": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.4.tgz", + "integrity": "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "enhanced-resolve": "^5.19.0", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.2.4" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bb6194 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "phantom_mcp", + "version": "1.0.0", + "description": "[![Hex.pm](https://img.shields.io/hexpm/v/phantom_mcp.svg)](https://hex.pm/packages/phantom_mcp) [![Documentation](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/phantom_mcp)", + "main": "index.js", + "directories": { + "doc": "doc", + "lib": "lib", + "test": "test" + }, + "scripts": { + "build": "npm run build:js && npm run build:css", + "build:js": "esbuild assets/js/preview.js --bundle --format=iife --minify --tree-shaking=true --target=es2020 --define:process.env.NODE_ENV=\\\"production\\\" --outfile=priv/static/preview.js", + "build:css": "tailwindcss -i assets/css/preview.css -o priv/static/preview.css --minify", + "build:test": "esbuild test/support/app/mcp/js/mcp_app.js --bundle --format=iife --minify --tree-shaking=true --target=es2020 --define:process.env.NODE_ENV=\\\"production\\\" --outfile=test/support/app/priv/static/mcp_app.js", + "watch": "npm run watch:js & npm run watch:css & npm run watch:test & wait", + "watch:js": "esbuild assets/js/preview.js --bundle --format=iife --target=es2020 --define:process.env.NODE_ENV=\\\"development\\\" --outfile=priv/static/preview.js --watch", + "watch:css": "tailwindcss -i assets/css/preview.css -o priv/static/preview.css --watch", + "watch:test": "esbuild test/support/app/mcp/js/mcp_app.js --bundle --format=iife --target=es2020 --define:process.env.NODE_ENV=\\\"development\\\" --outfile=test/support/app/priv/static/mcp_app.js --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dbernheisel/phantom_mcp.git" + }, + "keywords": [], + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/dbernheisel/phantom_mcp/issues" + }, + "homepage": "https://github.com/dbernheisel/phantom_mcp#readme", + "dependencies": { + "@mcp-ui/client": "^7.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.2.4", + "esbuild": "^0.28.0", + "tailwindcss": "^4.2.4" + } +} diff --git a/priv/static/preview.css b/priv/static/preview.css new file mode 100644 index 0000000..12d0bc3 --- /dev/null +++ b/priv/static/preview.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-white:#fff;--spacing:.25rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--shadow-sm:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-phantom:oklch(65% .15 280);--color-phantom-glow:oklch(72% .18 280);--color-phantom-dim:oklch(45% .12 280);--color-phantom-surface:oklch(22% .02 270);--color-phantom-surface-hover:oklch(26% .025 270)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.top-\[41px\]{top:41px}.z-10{z-index:10}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing) * 0)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.h-4{height:calc(var(--spacing) * 4)}.h-full{height:100%}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-screen{min-height:100vh}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-ew-resize{cursor:ew-resize}.touch-none{touch-action:none}.resize{resize:both}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.overflow-hidden{overflow:hidden}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-phantom\/25{border-color:#7f82e840}@supports (color:color-mix(in lab, red, red)){.border-phantom\/25{border-color:color-mix(in oklab, var(--color-phantom) 25%, transparent)}}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/50{border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/50{border-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.border-zinc-800\/60{border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.border-zinc-800\/60{border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.canvas-bg{background-color:#f4f4f6;background-image:radial-gradient(circle,#d0d0d8 .75px,#0000 .75px);background-size:16px 16px}.bg-phantom-surface{background-color:var(--color-phantom-surface)}.bg-phantom\/10{background-color:#7f82e81a}@supports (color:color-mix(in lab, red, red)){.bg-phantom\/10{background-color:color-mix(in oklab, var(--color-phantom) 10%, transparent)}}.bg-phantom\/15{background-color:#7f82e826}@supports (color:color-mix(in lab, red, red)){.bg-phantom\/15{background-color:color-mix(in oklab, var(--color-phantom) 15%, transparent)}}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-800\/50{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.bg-zinc-800\/50{background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-0{padding:calc(var(--spacing) * 0)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-12{padding:calc(var(--spacing) * 12)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-\[0\.15em\]{--tw-tracking:.15em;letter-spacing:.15em}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-phantom{color:var(--color-phantom)}.text-phantom\/70{color:#7f82e8b3}@supports (color:color-mix(in lab, red, red)){.text-phantom\/70{color:color-mix(in oklab, var(--color-phantom) 70%, transparent)}}.text-white{color:var(--color-white)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-200{color:var(--color-zinc-200)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-30{opacity:.3}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:translate-x-0\.5:is(:where(.group):hover *){--tw-translate-x:calc(var(--spacing) * .5);translate:var(--tw-translate-x) var(--tw-translate-y)}.group-hover\:text-phantom:is(:where(.group):hover *){color:var(--color-phantom)}.group-hover\:text-phantom-glow:is(:where(.group):hover *){color:var(--color-phantom-glow)}.hover\:border-phantom\/40:hover{border-color:#7f82e866}@supports (color:color-mix(in lab, red, red)){.hover\:border-phantom\/40:hover{border-color:color-mix(in oklab, var(--color-phantom) 40%, transparent)}}.hover\:bg-phantom-surface-hover:hover{background-color:var(--color-phantom-surface-hover)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-zinc-100:hover{color:var(--color-zinc-100)}.hover\:shadow-\[0_0_20px_-4px\]:hover{--tw-shadow:0 0 20px -4px var(--tw-shadow-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-phantom\/15:hover{--tw-shadow-color:#7f82e826}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-phantom\/15:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-phantom) 15%, transparent) var(--tw-shadow-alpha), transparent)}}}}[data-host-theme=dark] .canvas-bg,.canvas-bg[data-host-theme=dark],body[data-host-theme=dark] #mcp-app-container.canvas-bg{background-color:oklch(14% .01 270);background-image:radial-gradient(circle,oklch(24% .015 270) .75px,#0000 .75px)}.mcp-app-frame{border:1px solid #d4d4d8;min-height:320px}body[data-host-theme=dark] .mcp-app-frame{border-color:oklch(30% .015 270)}.mcp-app-frame-handle:before{content:"";background-color:var(--color-zinc-300);border-radius:3px;width:3px;height:32px;transition:background .2s,height .2s,box-shadow .2s;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}@media (prefers-color-scheme:dark){.mcp-app-frame-handle:before{background-color:var(--color-zinc-600)}}.mcp-app-frame-handle:hover:before,.mcp-app-frame-handle.is-dragging:before{background-color:var(--color-phantom);height:48px;box-shadow:0 0 8px var(--color-phantom-glow)}body.mcp-resizing,body.mcp-resizing *{cursor:ew-resize!important}body.mcp-resizing iframe{pointer-events:none!important}.phantom-control-group{align-items:center;gap:.375rem;display:flex}.phantom-control-label{text-transform:uppercase;letter-spacing:.12em;color:oklch(45% .01 270);white-space:nowrap;font-size:.6875rem;font-weight:700}.phantom-control-select{appearance:none;color:oklch(65% .01 270);cursor:pointer;background-color:oklch(16% .01 270);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-position:right .375rem center;background-repeat:no-repeat;border:1px solid oklch(30% .015 270);border-radius:.375rem;padding:.25rem 1.25rem .25rem .5rem;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,monospace;font-size:.6875rem;line-height:1;transition:border-color .15s,color .15s}.phantom-control-select:hover{border-color:var(--color-phantom-dim);color:oklch(80% .01 270)}.phantom-control-select:focus{border-color:var(--color-phantom);box-shadow:0 0 0 1px var(--color-phantom-dim);outline:none}.mcp-chat-container{flex-direction:column;flex:1;gap:1rem;width:100%;max-width:56rem;min-height:0;margin:0 auto;padding-top:1rem;display:flex;overflow-y:auto}.mcp-chat-message{flex-direction:column;display:flex}.mcp-chat-user{align-items:flex-end}.mcp-chat-assistant{align-items:flex-start}.mcp-chat-bubble{max-width:80%;padding:.625rem .875rem;font-size:.875rem;line-height:1.5}.mcp-chat-bubble-user{color:#fff;background-color:oklch(55% .14 280);border-radius:1rem 1rem .25rem}.mcp-chat-bubble-assistant{color:#1f2937;background-color:#fff;border:1px solid #e5e7eb;border-radius:1rem 1rem 1rem .25rem}body[data-host-theme=dark] .mcp-chat-bubble-user{color:oklch(96% .01 270);background-color:oklch(42% .1 280)}body[data-host-theme=dark] .mcp-chat-bubble-assistant{color:oklch(86% .01 270);background-color:oklch(22% .015 270);border-color:oklch(30% .015 270)}body[data-host-theme=dark] .mcp-chat-tool-label{color:oklch(62% .01 270)}.mcp-chat-tool-result{flex-direction:column;gap:.375rem;display:flex}.mcp-chat-tool-label{text-transform:uppercase;letter-spacing:.08em;color:#6b7280;padding-left:.25rem;font-size:.6875rem;font-weight:600}.mcp-chat-tool-body{flex-direction:row;align-items:stretch;display:flex}.mcp-pip-window{z-index:20;resize:both;background:#fff;border:1px solid #d4d4d8;border-radius:.75rem;flex-direction:column;width:380px;min-width:240px;height:320px;min-height:180px;display:flex;position:absolute;bottom:1rem;right:1rem;overflow:hidden;box-shadow:0 8px 32px #0f172a2e,0 0 0 1px #0f172a0a}.mcp-pip-header{cursor:grab;-webkit-user-select:none;user-select:none;background:#f4f4f5;border-bottom:1px solid #e4e4e7;flex-shrink:0;align-items:center;padding:.375rem .75rem;display:flex}.mcp-pip-header:active{cursor:grabbing}.mcp-pip-title{text-transform:uppercase;letter-spacing:.08em;color:#52525b;font-size:.6875rem;font-weight:600}body[data-host-theme=dark] .mcp-pip-window{background:oklch(16% .01 270);border-color:oklch(35% .02 270);box-shadow:0 8px 32px #0006,0 0 0 1px #ffffff0a}body[data-host-theme=dark] .mcp-pip-header{background:oklch(20% .015 270);border-bottom-color:oklch(30% .015 270)}body[data-host-theme=dark] .mcp-pip-title{color:oklch(60% .01 270)}.mcp-pip-body{flex:1;min-height:0;overflow:hidden}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0} \ No newline at end of file diff --git a/start.exs b/start.exs index 7648cc3..c21a7a5 100755 --- a/start.exs +++ b/start.exs @@ -1,4 +1,17 @@ #!/usr/bin/env iex + +# epmd -daemon && iex --name primary@127.0.0.1 -S mix run start.exs + +# Bundle assets (initial build) +{output, 0} = System.cmd("npm", ["run", "build"], stderr_to_stdout: true) +IO.puts(output) +{output, 0} = System.cmd("npm", ["run", "build:test"], stderr_to_stdout: true) +IO.puts(output) + +# File watchers rebuild on change; output goes to stderr to avoid garbling +spawn(fn -> System.cmd("npm", ["run", "watch"], into: IO.stream(:stderr, :line)) end) + +Application.put_env(:mime, :types, %{"text/event-stream" => ["sse"]}) Application.put_env(:phoenix, :json_library, JSON) Application.put_env(:phoenix, :plug_init_mode, :runtime) Application.put_env(:phoenix, :serve_endpoints, true, persistent: true) @@ -15,30 +28,25 @@ Application.put_env(:phantom_mcp, Test.Endpoint, ], pubsub_server: Test.PubSub, code_reloader: true, - http: [ip: {127, 0, 0, 1}, port: 4000], + reloadable_apps: [:phantom_mcp], + live_reload: [ + patterns: [ + ~r"lib/phantom/.*(ex)$", + ~r"priv/static/.*(js|css)$", + ~r"test/support/.*(ex)$", + ~r"test/support/app/priv/.*(js|css)$" + ] + ], + http: [ip: {127, 0, 0, 1}, port: 4001], server: true, secret_key_base: String.duplicate("a", 64) ) -Mix.install( - [ - {:plug_cowboy, "~> 2.7"}, - {:bandit, "~> 1.7"}, - {:tidewave, "~> 0.1.9"}, - {:phoenix, "~> 1.7"}, - {:phantom_mcp, path: "."} - ], - config: [ - mime: [ - types: %{ - "text/event-stream" => ["sse"] - } - ] - ] -) - Enum.each( ~w[ + test/support/app/mcp/layouts.ex + test/support/app/mcp/sample_app.ex + test/support/app/mcp/minimal_app.ex test/support/app/mcp/router.ex test/support/app/router.ex test/support/app/endpoint.ex @@ -227,6 +235,9 @@ defmodule PeerNode do cwd = File.cwd!() for file <- ~w[ + test/support/app/mcp/layouts.ex + test/support/app/mcp/sample_app.ex + test/support/app/mcp/minimal_app.ex test/support/app/mcp/router.ex test/support/app/router.ex test/support/app/endpoint.ex @@ -268,6 +279,7 @@ plug_opts = [router: Test.MCP.Router, pubsub: Test.PubSub, validate_origin: fals [ {Phoenix.PubSub, name: Test.PubSub}, {Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: Test.PubSub]}, + Test.Endpoint, {Bandit, plug: {Phantom.Test.ClusterPlug, phantom_opts: plug_opts}, port: 4002, @@ -282,16 +294,18 @@ peer = PeerNode.spawn!(4003) {:ok, _lb} = Supervisor.start_link( - [{LoadBalancer, port: 4000, backends: [4002, 4003]}], + [{LoadBalancer, port: 4001, backends: [4002, 4000]}], strategy: :one_for_one, name: :lb_sup ) IO.puts(""" - Load Balancer: http://localhost:4000/mcp (round-robin) - Primary node: http://localhost:4002/mcp (#{node()}) - Peer node: http://localhost:4003/mcp (#{peer}) + Primary node: http://localhost:4000/mcp (#{node()}) + Tidewave: http://localhost:4000/tidewave/mcp + Load Balancer: http://localhost:4001/mcp (round-robin) + App Preview: http://localhost:4001/mcp-apps + Peer node: http://localhost:4002/mcp (#{peer}) """) IEx.Server.run(env: __ENV__, binding: binding(), register: false) diff --git a/test/phantom/app_csp_test.exs b/test/phantom/app_csp_test.exs new file mode 100644 index 0000000..55df6ce --- /dev/null +++ b/test/phantom/app_csp_test.exs @@ -0,0 +1,128 @@ +defmodule Phantom.App.CSPTest do + use ExUnit.Case, async: true + import Plug.Test + import Plug.Conn + + alias Phantom.App.CSP + + describe "build/1" do + test "builds restrictive default CSP when no options given" do + csp = CSP.build([]) + + assert csp =~ "default-src 'none'" + assert csp =~ "script-src 'self' 'unsafe-inline'" + assert csp =~ "style-src 'self' 'unsafe-inline'" + assert csp =~ "img-src 'self' data:" + assert csp =~ "media-src 'self' data:" + assert csp =~ "connect-src 'none'" + end + + test "adds connect_domains to connect-src" do + csp = CSP.build(connect_domains: ["https://api.example.com", "https://ws.example.com"]) + + assert csp =~ "connect-src 'self' https://api.example.com https://ws.example.com" + end + + test "adds resource_domains to script/img/style/font/media-src" do + csp = CSP.build(resource_domains: ["https://cdn.example.com"]) + + assert csp =~ "script-src 'self' 'unsafe-inline' https://cdn.example.com" + assert csp =~ "style-src 'self' 'unsafe-inline' https://cdn.example.com" + assert csp =~ "img-src 'self' data: https://cdn.example.com" + assert csp =~ "font-src 'self' https://cdn.example.com" + assert csp =~ "media-src 'self' data: https://cdn.example.com" + end + + test "adds frame_domains to frame-src" do + csp = CSP.build(frame_domains: ["https://embed.example.com"]) + + assert csp =~ "frame-src https://embed.example.com" + end + + test "adds base_uri_domains to base-uri" do + csp = CSP.build(base_uri_domains: ["https://base.example.com"]) + + assert csp =~ "base-uri https://base.example.com" + end + + test "combines multiple domain types" do + csp = + CSP.build( + connect_domains: ["https://api.example.com"], + resource_domains: ["https://cdn.example.com"], + frame_domains: ["https://embed.example.com"] + ) + + assert csp =~ "connect-src 'self' https://api.example.com" + assert csp =~ "script-src 'self' 'unsafe-inline' https://cdn.example.com" + assert csp =~ "frame-src https://embed.example.com" + end + end + + describe "build_from_ui/1" do + test "builds CSP from a Phantom.UI struct" do + ui = + Phantom.UI.build( + resource_uri: "ui:///app", + connect_domains: ["https://api.example.com"], + resource_domains: ["https://cdn.example.com"] + ) + + csp = CSP.build_from_ui(ui) + + assert csp =~ "connect-src 'self' https://api.example.com" + assert csp =~ "script-src 'self' 'unsafe-inline' https://cdn.example.com" + end + + test "returns restrictive default for nil" do + csp = CSP.build_from_ui(nil) + assert csp =~ "default-src 'none'" + end + end + + describe "put_content_security_policy/2 plug" do + test "sets Content-Security-Policy header" do + conn = + conn(:get, "/") + |> CSP.put_content_security_policy(connect_domains: ["https://api.example.com"]) + + [csp] = get_resp_header(conn, "content-security-policy") + assert csp =~ "connect-src 'self' https://api.example.com" + end + + test "sets restrictive default when no options" do + conn = + conn(:get, "/") + |> CSP.put_content_security_policy([]) + + [csp] = get_resp_header(conn, "content-security-policy") + assert csp =~ "default-src 'none'" + end + end + + describe "call/2 stores CSP metadata in conn.private" do + test "stores CSP domains in conn.private[:phantom_ui_csp]" do + conn = + conn(:get, "/") + |> CSP.call(CSP.init(connect_domains: ["https://api.example.com"])) + + assert %{connectDomains: ["https://api.example.com"]} = conn.private[:phantom_ui_csp] + end + + test "returns nil when no CSP domains" do + conn = + conn(:get, "/") + |> CSP.call(CSP.init([])) + + assert conn.private[:phantom_ui_csp] == nil + end + + test "ignores non-CSP options" do + conn = + conn(:get, "/") + |> CSP.call(CSP.init(permissions: [:camera], domain: "example.com")) + + assert conn.private[:phantom_ui_csp] == nil + end + end +end diff --git a/test/phantom/app_preview_test.exs b/test/phantom/app_preview_test.exs new file mode 100644 index 0000000..38d8243 --- /dev/null +++ b/test/phantom/app_preview_test.exs @@ -0,0 +1,171 @@ +defmodule Phantom.App.PreviewTest do + use ExUnit.Case, async: true + import Plug.Test + import Plug.Conn + + alias Phantom.App.Preview + + defmodule PreviewApp do + use Phantom.App + + plug Phantom.App.CSP, + connect_domains: ["https://api.example.com"] + + @impl true + def render(_assigns), do: "

    Preview App

    " + end + + defmodule PreviewRouter do + use Phantom.Router, name: "PreviewTest", vsn: "1.0" + + @description "An app for preview testing" + tool :preview_app, app: PreviewApp + + def preview_app(_params, session), + do: {:reply, Phantom.Tool.text("ok"), session} + end + + setup do + Phantom.Cache.register(PreviewRouter) + opts = Preview.init(router: PreviewRouter) + %{opts: opts} + end + + describe "GET /" do + test "lists only ui:// resource templates", %{opts: opts} do + conn = + conn(:get, "/") + |> Preview.call(opts) + + assert conn.status == 200 + body = conn.resp_body + assert body =~ "preview_app" + end + + test "returns HTML content type", %{opts: opts} do + conn = + conn(:get, "/") + |> Preview.call(opts) + + [content_type] = get_resp_header(conn, "content-type") + assert content_type =~ "text/html" + end + end + + describe "GET /:name" do + test "renders the app HTML", %{opts: opts} do + conn = + conn(:get, "/preview_app") + |> Preview.call(opts) + + assert conn.status == 200 + assert conn.resp_body =~ "

    Preview App

    " + end + + test "sets CSP header from app's plug pipeline", %{opts: opts} do + conn = + conn(:get, "/preview_app") + |> Preview.call(opts) + + [csp] = get_resp_header(conn, "content-security-policy") + assert csp =~ "connect-src" + assert csp =~ "https://api.example.com" + end + + test "returns 404 for unknown app", %{opts: opts} do + conn = + conn(:get, "/nonexistent") + |> Preview.call(opts) + + assert conn.status == 404 + end + end + + describe "GET /:name/frame" do + test "renders AppBridge host page", %{opts: opts} do + conn = + conn(:get, "/preview_app/frame") + |> Preview.call(opts) + + assert conn.status == 200 + body = conn.resp_body + assert body =~ "mcp-app-container" + assert body =~ "data-app-name=\"preview_app\"" + assert body =~ "data-app-html=" + assert body =~ "_assets/preview.js" + end + + test "renders host context controls bar", %{opts: opts} do + conn = + conn(:get, "/preview_app/frame") + |> Preview.call(opts) + + body = conn.resp_body + assert body =~ "phantom-controls" + assert body =~ "phantom-theme" + assert body =~ "phantom-platform" + assert body =~ "phantom-display-mode" + assert body =~ "phantom-client-preset" + end + + test "controls bar includes expected options", %{opts: opts} do + conn = + conn(:get, "/preview_app/frame") + |> Preview.call(opts) + + body = conn.resp_body + + # Theme options + assert body =~ ~s(value="light") + assert body =~ ~s(value="dark") + + # Platform options + assert body =~ ~s(value="web") + assert body =~ ~s(value="desktop") + assert body =~ ~s(value="mobile") + + # Display mode options + assert body =~ ~s(value="inline") + assert body =~ ~s(value="fullscreen") + assert body =~ ~s(value="pip") + + # Client preset options + assert body =~ ~s(value="none") + assert body =~ "Default" + assert body =~ "Claude Desktop" + end + end + + describe "GET /_assets" do + test "serves preview.js", %{opts: opts} do + conn = + :get + |> conn("/_assets/preview.js") + |> Preview.call(opts) + + assert conn.status == 200 + [content_type] = get_resp_header(conn, "content-type") + assert content_type =~ "javascript" + end + + test "serves preview.css", %{opts: opts} do + conn = + :get + |> conn("/_assets/preview.css") + |> Preview.call(opts) + + assert conn.status == 200 + [content_type] = get_resp_header(conn, "content-type") + assert content_type =~ "css" + end + + test "returns 404 for unknown assets", %{opts: opts} do + conn = + :get + |> conn("/_assets/evil.js") + |> Preview.call(opts) + + assert conn.status == 404 + end + end +end diff --git a/test/phantom/app_resource_test.exs b/test/phantom/app_resource_test.exs new file mode 100644 index 0000000..a4c74ae --- /dev/null +++ b/test/phantom/app_resource_test.exs @@ -0,0 +1,86 @@ +defmodule Phantom.AppResourceTest do + use ExUnit.Case, async: true + + defmodule DashboardApp do + use Phantom.App + + @impl true + def render(_assigns), do: "

    Dashboard

    " + end + + defmodule FunctionHandlerRouter do + use Phantom.Router, name: "FunctionHandlerTest", vsn: "1.0" + + @description "Tool with an app" + tool :dashboard, app: DashboardApp + def dashboard(_params, session), do: {:reply, Phantom.Tool.text("ok"), session} + end + + defmodule ToolWithSchemaRouter do + use Phantom.Router, name: "ToolWithSchemaTest", vsn: "1.0" + + @description "Tool with app and input schema" + tool :search, app: DashboardApp do + field :query, :string, required: true + end + + def search(_params, session), do: {:reply, Phantom.Tool.text("ok"), session} + end + + describe "tool with app: option" do + test "creates both tool and ui:// resource template" do + info = FunctionHandlerRouter.__phantom__(:info) + + tool = Enum.find(info.tools, &(&1.name == "dashboard")) + assert tool + assert tool.ui + assert tool.ui.resource_uri == "ui:///dashboard" + + template = Enum.find(info.resource_templates, &(&1.name == "dashboard")) + assert template + assert template.scheme == "ui" + assert template.mime_type == "text/html;profile=mcp-app" + assert template.handler == DashboardApp + assert template.function == :__phantom_app__ + end + + test "tool description comes from @description" do + info = FunctionHandlerRouter.__phantom__(:info) + tool = Enum.find(info.tools, &(&1.name == "dashboard")) + assert tool.description == "Tool with an app" + end + + test "tool to_json includes _meta.ui.resourceUri" do + info = FunctionHandlerRouter.__phantom__(:info) + tool = Enum.find(info.tools, &(&1.name == "dashboard")) + json = Phantom.Tool.to_json(tool) + + assert %{_meta: %{ui: %{resourceUri: "ui:///dashboard"}}} = json + end + + test "works with input schema do block" do + info = ToolWithSchemaRouter.__phantom__(:info) + + tool = Enum.find(info.tools, &(&1.name == "search")) + assert tool + assert tool.ui.resource_uri == "ui:///search" + assert tool.input_schema + + template = Enum.find(info.resource_templates, &(&1.name == "search")) + assert template + assert template.handler == DashboardApp + end + end + + describe "resource macro rejects ui:// scheme" do + test "raises when resource uses ui:// scheme" do + assert_raise RuntimeError, ~r/ui:\/\/ scheme is reserved/, fn -> + defmodule BadRouter do + use Phantom.Router, name: "Bad", vsn: "1.0" + resource "ui:///foo", :handler, description: "should fail" + def handler(_p, s), do: {:reply, %{}, s} + end + end + end + end +end diff --git a/test/phantom/app_test.exs b/test/phantom/app_test.exs new file mode 100644 index 0000000..68031f9 --- /dev/null +++ b/test/phantom/app_test.exs @@ -0,0 +1,234 @@ +defmodule Phantom.AppTest do + use ExUnit.Case, async: true + + defp app_html({:reply, %{contents: [%{text: html} | _]}, _session}), do: html + defp app_meta({:reply, %{_meta: meta}, _session}), do: meta + + defmodule SimpleApp do + use Phantom.App + + @impl true + def render(_assigns) do + "

    Plain HTML

    " + end + end + + defmodule MountApp do + use Phantom.App + + @impl true + def mount(_params, session) do + {:ok, %{greeting: "Hello from #{session.assigns[:user_name] || "World"}"}} + end + + @impl true + def render(assigns) do + "

    #{assigns.greeting}

    " + end + end + + defmodule AppWithCSP do + use Phantom.App, + permissions: [:clipboard_write], + prefers_border: true + + plug Phantom.App.CSP, + connect_domains: ["https://api.example.com"] + + @impl true + def render(_assigns) do + "

    Dashboard

    " + end + end + + describe "basic behaviour" do + test "generates __phantom_app__/2 handler" do + assert function_exported?(SimpleApp, :__phantom_app__, 2) + end + + test "renders plain HTML" do + session = Phantom.Session.new("test") + result = SimpleApp.__phantom_app__(%{}, session) + + assert app_html(result) == "

    Plain HTML

    " + end + + test "mount receives params and session" do + session = Phantom.Session.new("test", assigns: %{user_name: "Alice"}) + result = MountApp.__phantom_app__(%{}, session) + + assert app_html(result) == "

    Hello from Alice

    " + end + + test "default mount returns empty assigns" do + session = Phantom.Session.new("test") + result = SimpleApp.__phantom_app__(%{}, session) + + assert app_html(result) == "

    Plain HTML

    " + end + + test "assigns include session and params" do + defmodule AssignsApp do + use Phantom.App + + @impl true + def render(assigns) do + "session:#{assigns.session.id},params:#{inspect(assigns.params)}" + end + end + + session = Phantom.Session.new("test-id") + result = AssignsApp.__phantom_app__(%{"key" => "val"}, session) + + assert app_html(result) =~ "session:test-id" + assert app_html(result) =~ ~s(params:%{"key" => "val"}) + end + end + + describe "plug pipeline" do + test "CSP plug runs and sets _meta.ui.csp in response" do + session = Phantom.Session.new("test") + result = AppWithCSP.__phantom_app__(%{}, session) + + assert app_html(result) == "

    Dashboard

    " + ui_meta = app_meta(result).ui + assert %{csp: %{connectDomains: ["https://api.example.com"]}} = ui_meta + assert %{permissions: %{clipboardWrite: %{}}} = ui_meta + assert ui_meta.prefersBorder == true + end + + test "app without plugs always includes _meta.ui" do + session = Phantom.Session.new("test") + result = SimpleApp.__phantom_app__(%{}, session) + + assert app_html(result) == "

    Plain HTML

    " + # _meta.ui is always present (host needs it for CSP defaults) + assert %{ui: _} = app_meta(result) + end + + test "CSP plug also sets Content-Security-Policy header on conn" do + conn = Plug.Test.conn(:get, "/") + conn = AppWithCSP.call(conn, AppWithCSP.init([])) + + [csp] = Plug.Conn.get_resp_header(conn, "content-security-policy") + assert csp =~ "connect-src" + assert csp =~ "https://api.example.com" + end + end + + describe "layout support" do + defmodule TestLayouts do + use Phoenix.Component + + def root(assigns) do + ~H""" + + + Layout Test + {@inner_content} + + """ + end + + def app(assigns) do + ~H""" +
    {@inner_content}
    + """ + end + end + + defmodule LayoutApp do + use Phantom.App + use Phoenix.Component + import Phoenix.Controller, only: [put_root_layout: 2] + + plug :put_root_layout, html: {TestLayouts, :root} + + @impl Phantom.App + def render(assigns) do + ~H"

    Content inside layout

    " + end + end + + defmodule BothLayoutsApp do + use Phantom.App + use Phoenix.Component + import Phoenix.Controller, only: [put_root_layout: 2, put_layout: 2] + + plug :put_root_layout, html: {TestLayouts, :root} + plug :put_layout, html: {TestLayouts, :app} + + @impl Phantom.App + def render(assigns) do + ~H"

    Nested

    " + end + end + + test "applies root layout from plug pipeline" do + session = Phantom.Session.new("test") + result = LayoutApp.__phantom_app__(%{}, session) + + html = app_html(result) + assert html =~ "" + assert html =~ "Layout Test" + assert html =~ "

    Content inside layout

    " + end + + test "applies both layout and root layout" do + session = Phantom.Session.new("test") + result = BothLayoutsApp.__phantom_app__(%{}, session) + + html = app_html(result) + assert html =~ "" + assert html =~ ~s(
    ) + assert html =~ "

    Nested

    " + end + + test "no layout when none configured" do + session = Phantom.Session.new("test") + result = SimpleApp.__phantom_app__(%{}, session) + + assert app_html(result) == "

    Plain HTML

    " + end + end + + describe "to_html/1" do + test "passes through binary strings" do + assert Phantom.App.to_html("

    Hello

    ") == "

    Hello

    " + end + + test "converts iodata to binary" do + assert Phantom.App.to_html(["

    ", "Hello", "

    "]) == "

    Hello

    " + end + end + + describe "tool app: integration" do + defmodule AppModuleRouter do + use Phantom.Router, + name: "AppModuleTest", + vsn: "1.0" + + @description "Dashboard app" + tool :dashboard, app: MountApp + def dashboard(_params, session), do: {:reply, Phantom.Tool.text("ok"), session} + end + + test "tool with app: creates resource template" do + info = AppModuleRouter.__phantom__(:info) + template = Enum.find(info.resource_templates, &(&1.name == "dashboard")) + + assert template + assert template.handler == MountApp + assert template.function == :__phantom_app__ + assert template.scheme == "ui" + assert template.mime_type == "text/html;profile=mcp-app" + end + + test "tool with app: sets _meta.ui.resourceUri" do + info = AppModuleRouter.__phantom__(:info) + tool = Enum.find(info.tools, &(&1.name == "dashboard")) + + assert tool.ui.resource_uri == "ui:///dashboard" + end + end +end diff --git a/test/phantom/tool_ui_test.exs b/test/phantom/tool_ui_test.exs new file mode 100644 index 0000000..e9e24c4 --- /dev/null +++ b/test/phantom/tool_ui_test.exs @@ -0,0 +1,95 @@ +defmodule Phantom.Tool.UITest do + use ExUnit.Case, async: true + + alias Phantom.Tool + alias Phantom.UI + + describe "build/1 with UI attrs" do + test "builds tool without UI" do + tool = + Tool.build( + name: "basic", + handler: __MODULE__, + function: :noop, + description: "A basic tool" + ) + + assert tool.ui == nil + end + + test "builds tool with UI keyword list" do + tool = + Tool.build( + name: "ui_tool", + handler: __MODULE__, + function: :noop, + description: "A UI tool", + ui: [resource_uri: "ui:///dashboard", visibility: ["model", "app"]] + ) + + assert %UI{} = tool.ui + assert tool.ui.resource_uri == "ui:///dashboard" + assert tool.ui.visibility == [:model, :app] + end + + test "builds tool with app-only visibility" do + tool = + Tool.build( + name: "app_tool", + handler: __MODULE__, + function: :noop, + description: "An app-only tool", + ui: [resource_uri: "ui:///app", visibility: ["app"]] + ) + + assert tool.ui.visibility == [:app] + end + end + + describe "to_json/1 with UI" do + test "omits _meta when no UI" do + tool = + Tool.build( + name: "basic", + handler: __MODULE__, + function: :noop, + description: "A basic tool" + ) + + json = Tool.to_json(tool) + refute Map.has_key?(json, :_meta) + end + + test "includes _meta.ui with resourceUri and visibility" do + tool = + Tool.build( + name: "ui_tool", + handler: __MODULE__, + function: :noop, + description: "A UI tool", + ui: [resource_uri: "ui:///dashboard", visibility: [:model, :app]] + ) + + json = Tool.to_json(tool) + + assert %{_meta: %{ui: ui_meta}} = json + assert ui_meta.resourceUri == "ui:///dashboard" + assert ui_meta.visibility == ["model", "app"] + end + + test "includes _meta.ui for app-only tools" do + tool = + Tool.build( + name: "app_tool", + handler: __MODULE__, + function: :noop, + description: "An app-only tool", + ui: [resource_uri: "ui:///app", visibility: ["app"]] + ) + + json = Tool.to_json(tool) + + assert %{_meta: %{ui: %{visibility: ["app"]}}} = json + end + end +end diff --git a/test/phantom/tool_visibility_test.exs b/test/phantom/tool_visibility_test.exs new file mode 100644 index 0000000..65532f6 --- /dev/null +++ b/test/phantom/tool_visibility_test.exs @@ -0,0 +1,54 @@ +defmodule Phantom.Tool.VisibilityTest do + use ExUnit.Case, async: false + + import Phantom.TestDispatcher + + describe "tools/list visibility filtering" do + setup do + Phantom.Cache.register(Test.MCP.Router) + :ok + end + + test "app-only tools are excluded from tools/list" do + Phantom.Cache.add_tool(Test.MCP.Router, %{ + name: "vis_app_only_tool", + handler: Test.MCP.Router, + function: :echo_tool, + description: "Only callable from the app", + ui: [resource_uri: "ui:///test-app", visibility: [:app]] + }) + + request_tool_list() + + assert_receive {:response, 1, "message", response} + tool_names = Enum.map(response.result.tools, & &1.name) + refute "vis_app_only_tool" in tool_names + end + + test "model+app tools are included in tools/list with _meta.ui" do + Phantom.Cache.add_tool(Test.MCP.Router, %{ + name: "vis_model_app_tool", + handler: Test.MCP.Router, + function: :echo_tool, + description: "Visible to both model and app", + ui: [resource_uri: "ui:///test-app", visibility: [:model, :app]] + }) + + request_tool_list() + + assert_receive {:response, 1, "message", response} + tool = Enum.find(response.result.tools, &(&1.name == "vis_model_app_tool")) + assert tool, "vis_model_app_tool should be in the tools list" + assert tool._meta.ui.resourceUri == "ui:///test-app" + assert tool._meta.ui.visibility == ["model", "app"] + end + + test "tools without UI are always included" do + request_tool_list() + + assert_receive {:response, 1, "message", response} + tool_names = Enum.map(response.result.tools, & &1.name) + assert "echo_tool" in tool_names + end + end +end diff --git a/test/phantom/ui_capability_test.exs b/test/phantom/ui_capability_test.exs new file mode 100644 index 0000000..c519e6b --- /dev/null +++ b/test/phantom/ui_capability_test.exs @@ -0,0 +1,78 @@ +defmodule Phantom.UI.CapabilityTest do + use ExUnit.Case, async: true + + alias Phantom.Session + + describe "ui_capability/3" do + test "adds extensions when ui:// resource templates exist" do + # Directly test the capability function with a mock session + cache setup + ui_template = + Phantom.ResourceTemplate.build( + uri: "ui:///test-app", + handler: __MODULE__, + function: :noop, + router: __MODULE__, + description: "Test app", + mime_type: "text/html;profile=mcp-app" + ) + + :persistent_term.put({Phantom, __MODULE__, :resource_templates}, [ui_template]) + + on_exit(fn -> :persistent_term.erase({Phantom, __MODULE__, :resource_templates}) end) + + session = Session.new("test-session") + + capabilities = + Phantom.Router.ui_capability(%{}, __MODULE__, session) + + assert %{extensions: extensions} = capabilities + + assert %{"io.modelcontextprotocol/ui" => %{mimeTypes: ["text/html;profile=mcp-app"]}} = + extensions + end + + test "omits extensions when no ui:// resource templates" do + non_ui_template = + Phantom.ResourceTemplate.build( + uri: "test:///resource", + handler: __MODULE__, + function: :noop, + router: __MODULE__, + description: "Not UI", + mime_type: "text/plain" + ) + + :persistent_term.put({Phantom, __MODULE__, :resource_templates}, [non_ui_template]) + + on_exit(fn -> :persistent_term.erase({Phantom, __MODULE__, :resource_templates}) end) + + session = Session.new("test-session") + + capabilities = + Phantom.Router.ui_capability(%{}, __MODULE__, session) + + refute Map.has_key?(capabilities, :extensions) + end + + test "omits extensions when no resource templates at all" do + :persistent_term.put({Phantom, __MODULE__, :resource_templates}, []) + on_exit(fn -> :persistent_term.erase({Phantom, __MODULE__, :resource_templates}) end) + + session = Session.new("test-session") + + capabilities = + Phantom.Router.ui_capability(%{}, __MODULE__, session) + + refute Map.has_key?(capabilities, :extensions) + end + end + + describe "client ui capability in session" do + test "Session has :ui in client_capabilities default" do + session = Session.new("test-session") + assert session.client_capabilities.ui == false + end + end + + def noop(_params, session), do: {:reply, %{}, session} +end diff --git a/test/phantom/ui_integration_test.exs b/test/phantom/ui_integration_test.exs new file mode 100644 index 0000000..7bec882 --- /dev/null +++ b/test/phantom/ui_integration_test.exs @@ -0,0 +1,67 @@ +defmodule Phantom.UI.IntegrationTest do + use ExUnit.Case, async: false + + import Phantom.TestDispatcher + + describe "full MCP Apps flow" do + setup do + Phantom.Cache.register(Test.MCP.Router) + + # Add a model+app tool linked to a UI resource + Phantom.Cache.add_tool(Test.MCP.Router, %{ + name: "integ_dashboard_refresh", + handler: Test.MCP.Router, + function: :echo_tool, + description: "Refresh dashboard data", + ui: [resource_uri: "ui:///dashboard", visibility: ["model", "app"]] + }) + + # Add an app-only tool (should not appear in tools/list) + Phantom.Cache.add_tool(Test.MCP.Router, %{ + name: "integ_app_submit_form", + handler: Test.MCP.Router, + function: :echo_tool, + description: "Submit form from dashboard", + ui: [resource_uri: "ui:///dashboard", visibility: ["app"]] + }) + + :ok + end + + test "tools/list includes model-visible UI tools with _meta, excludes app-only" do + request_tool_list() + + assert_receive {:response, 1, "message", response} + tools = response.result.tools + tool_names = Enum.map(tools, & &1.name) + + # Model+app tool is included + assert "integ_dashboard_refresh" in tool_names + + # App-only tool is excluded + refute "integ_app_submit_form" in tool_names + + # UI tool has _meta.ui + dashboard_tool = Enum.find(tools, &(&1.name == "integ_dashboard_refresh")) + assert dashboard_tool._meta.ui.resourceUri == "ui:///dashboard" + assert dashboard_tool._meta.ui.visibility == ["model", "app"] + end + + test "tools/call works for app-only tool" do + request_tool("integ_app_submit_form", %{"message" => "form data"}) + + assert_receive {:response, 1, "message", response} + assert response.result + refute response[:error] + end + + test "regular tools without UI have no _meta" do + request_tool_list() + + assert_receive {:response, 1, "message", response} + echo_tool = Enum.find(response.result.tools, &(&1.name == "echo_tool")) + assert echo_tool + refute Map.has_key?(echo_tool, :_meta) + end + end +end diff --git a/test/phantom/ui_resource_meta_test.exs b/test/phantom/ui_resource_meta_test.exs new file mode 100644 index 0000000..4ba8532 --- /dev/null +++ b/test/phantom/ui_resource_meta_test.exs @@ -0,0 +1,101 @@ +defmodule Phantom.UI.ResourceMetaTest do + use ExUnit.Case, async: true + + alias Phantom.ResourceTemplate + + describe "ResourceTemplate.to_json/1 with UI metadata" do + test "includes _meta.ui when UI metadata present in meta" do + template = + ResourceTemplate.build( + uri: "ui:///test-app", + handler: __MODULE__, + function: :noop, + router: __MODULE__, + description: "Test app", + mime_type: "text/html;profile=mcp-app", + meta: %{ + file: "test.ex", + line: 1, + ui: + Phantom.UI.build( + connect_domains: ["https://api.example.com"], + permissions: [:camera], + prefers_border: true + ) + } + ) + + json = ResourceTemplate.to_json(template) + + assert %{_meta: %{ui: ui_meta}} = json + assert %{csp: %{connectDomains: ["https://api.example.com"]}} = ui_meta + assert %{permissions: %{camera: %{}}} = ui_meta + assert ui_meta.prefersBorder == true + end + + test "omits _meta when no UI metadata in meta" do + template = + ResourceTemplate.build( + uri: "test:///resource", + handler: __MODULE__, + function: :noop, + router: __MODULE__, + description: "Regular resource", + mime_type: "text/plain" + ) + + json = ResourceTemplate.to_json(template) + + refute Map.has_key?(json, :_meta) + end + + test "omits _meta when meta has only file/line" do + template = + ResourceTemplate.build( + uri: "test:///resource", + handler: __MODULE__, + function: :noop, + router: __MODULE__, + description: "Regular resource", + mime_type: "text/plain", + meta: %{file: "test.ex", line: 1} + ) + + json = ResourceTemplate.to_json(template) + + refute Map.has_key?(json, :_meta) + end + end + + describe "ResourcePlug _meta injection" do + test "resource response includes _meta.ui for UI resource" do + ui = Phantom.UI.build(connect_domains: ["https://api.example.com"], permissions: [:camera]) + spec = %{meta: %{ui: ui}} + request = %Phantom.Request{id: 1, spec: spec} + session = Phantom.Session.new("test", request: request) + + result = {:reply, %{text: "test"}, session} + + {:reply, response, _session} = + Phantom.Request.resource_response(result, "ui:///test-app", session) + + assert %{_meta: %{ui: ui_meta}} = response + assert %{csp: %{connectDomains: ["https://api.example.com"]}} = ui_meta + end + + test "resource response omits _meta for non-UI resource" do + spec = %{meta: %{file: "test.ex", line: 1}} + request = %Phantom.Request{id: 1, spec: spec} + session = Phantom.Session.new("test", request: request) + + result = {:reply, %{text: "hello"}, session} + + {:reply, response, _session} = + Phantom.Request.resource_response(result, "test:///resource", session) + + refute Map.has_key?(response, :_meta) + end + end + + def noop(_params, session), do: {:reply, %{}, session} +end diff --git a/test/phantom/ui_test.exs b/test/phantom/ui_test.exs new file mode 100644 index 0000000..4363a0d --- /dev/null +++ b/test/phantom/ui_test.exs @@ -0,0 +1,178 @@ +defmodule Phantom.UITest do + use ExUnit.Case, async: true + + alias Phantom.UI + + describe "build/1" do + test "returns nil when no UI attrs present" do + assert UI.build([]) == nil + assert UI.build(%{}) == nil + end + + test "builds from keyword list with resource_uri" do + ui = UI.build(resource_uri: "ui:///dashboard") + + assert %UI{} = ui + assert ui.resource_uri == "ui:///dashboard" + assert ui.visibility == [:model, :app] + end + + test "builds from map" do + ui = UI.build(%{resource_uri: "ui:///app", visibility: [:app]}) + + assert ui.resource_uri == "ui:///app" + assert ui.visibility == [:app] + end + + test "builds with CSP domains" do + ui = + UI.build( + resource_uri: "ui:///app", + connect_domains: ["https://api.example.com"], + resource_domains: ["https://cdn.example.com"], + frame_domains: ["https://embed.example.com"], + base_uri_domains: ["https://base.example.com"] + ) + + assert ui.connect_domains == ["https://api.example.com"] + assert ui.resource_domains == ["https://cdn.example.com"] + assert ui.frame_domains == ["https://embed.example.com"] + assert ui.base_uri_domains == ["https://base.example.com"] + end + + test "builds with permissions" do + ui = + UI.build(resource_uri: "ui:///app", permissions: [:camera, :microphone, :clipboard_write]) + + assert ui.permissions == [:camera, :microphone, :clipboard_write] + end + + test "builds with domain and prefers_border" do + ui = UI.build(resource_uri: "ui:///app", domain: "example.com", prefers_border: true) + + assert ui.domain == "example.com" + assert ui.prefers_border == true + end + + test "raises on invalid visibility values" do + assert_raise ArgumentError, ~r/invalid visibility values: \[:unknown\]/, fn -> + UI.build(resource_uri: "ui:///app", visibility: [:unknown]) + end + end + + test "raises on mixed valid and invalid visibility" do + assert_raise ArgumentError, ~r/invalid visibility values: \[:nope\]/, fn -> + UI.build(resource_uri: "ui:///app", visibility: [:model, :nope]) + end + end + end + + describe "to_tool_meta/1" do + test "returns nil for nil" do + assert UI.to_tool_meta(nil) == nil + end + + test "returns _meta map with resourceUri and visibility as strings" do + ui = UI.build(resource_uri: "ui:///dashboard", visibility: [:model, :app]) + + assert UI.to_tool_meta(ui) == %{ + ui: %{ + resourceUri: "ui:///dashboard", + visibility: ["model", "app"] + } + } + end + + test "returns app-only visibility as string" do + ui = UI.build(resource_uri: "ui:///app", visibility: [:app]) + + assert %{ui: %{visibility: ["app"]}} = UI.to_tool_meta(ui) + end + end + + describe "to_resource_meta/1" do + test "returns nil for nil" do + assert UI.to_resource_meta(nil) == nil + end + + test "returns nil when no resource-side metadata" do + ui = UI.build(resource_uri: "ui:///app") + assert UI.to_resource_meta(ui) == nil + end + + test "returns _meta map with CSP domains" do + ui = + UI.build( + resource_uri: "ui:///app", + connect_domains: ["https://api.example.com"], + resource_domains: ["https://cdn.example.com"] + ) + + meta = UI.to_resource_meta(ui) + + assert %{ui: %{csp: csp}} = meta + assert csp.connectDomains == ["https://api.example.com"] + assert csp.resourceDomains == ["https://cdn.example.com"] + end + + test "returns _meta map with permissions" do + ui = UI.build(resource_uri: "ui:///app", permissions: [:camera, :geolocation]) + + meta = UI.to_resource_meta(ui) + + assert %{ui: %{permissions: permissions}} = meta + assert permissions == %{camera: %{}, geolocation: %{}} + end + + test "returns _meta with domain and prefers_border" do + ui = UI.build(resource_uri: "ui:///app", domain: "example.com", prefers_border: true) + + meta = UI.to_resource_meta(ui) + + assert %{ui: %{domain: "example.com", prefersBorder: true}} = meta + end + + test "omits nil CSP and permission fields" do + ui = UI.build(resource_uri: "ui:///app", connect_domains: ["https://api.example.com"]) + + meta = UI.to_resource_meta(ui) + + assert %{ui: ui_meta} = meta + assert Map.has_key?(ui_meta, :csp) + refute Map.has_key?(ui_meta, :permissions) + refute Map.has_key?(ui_meta, :domain) + refute Map.has_key?(ui_meta, :prefersBorder) + end + end + + describe "model_visible?/1" do + test "returns true for tools without UI" do + tool = Phantom.Tool.build(name: "basic", handler: __MODULE__, function: :noop) + assert UI.model_visible?(tool) == true + end + + test "returns true when visibility includes :model" do + tool = + Phantom.Tool.build( + name: "ui_tool", + handler: __MODULE__, + function: :noop, + ui: [resource_uri: "ui:///app", visibility: [:model, :app]] + ) + + assert UI.model_visible?(tool) == true + end + + test "returns false when visibility is app-only" do + tool = + Phantom.Tool.build( + name: "app_tool", + handler: __MODULE__, + function: :noop, + ui: [resource_uri: "ui:///app", visibility: [:app]] + ) + + assert UI.model_visible?(tool) == false + end + end +end diff --git a/test/support/app/endpoint.ex b/test/support/app/endpoint.ex index 0b0d810..329f198 100644 --- a/test/support/app/endpoint.ex +++ b/test/support/app/endpoint.ex @@ -5,6 +5,12 @@ defmodule Test.Endpoint do plug Tidewave end + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + plug Plug.Parsers, parsers: [{:json, length: 1_000_000}], pass: ["application/json"], diff --git a/test/support/app/mcp/js/mcp_app.js b/test/support/app/mcp/js/mcp_app.js new file mode 100644 index 0000000..4c16f01 --- /dev/null +++ b/test/support/app/mcp/js/mcp_app.js @@ -0,0 +1,127 @@ +/** + * MCP App client — initializes the postMessage connection with the host + * and dispatches DOM events for inline scripts to consume. + * + * Loaded via the root layout's + + + {@inner_content} + + + """ + end + + def app(assigns) do + ~H""" +
    + {@inner_content} +
    + """ + end +end diff --git a/test/support/app/mcp/minimal_app.ex b/test/support/app/mcp/minimal_app.ex new file mode 100644 index 0000000..c7b3dd9 --- /dev/null +++ b/test/support/app/mcp/minimal_app.ex @@ -0,0 +1,21 @@ +defmodule Test.MCP.MinimalApp do + @moduledoc "Minimal app — no plugs, no CSP, no mount, no layout." + use Phantom.App + + @mcp_app_js_path Path.join(__DIR__, "js/mcp_app.js") + @external_resource @mcp_app_js_path + @mcp_app_js_b64 @mcp_app_js_path |> File.read!() |> Base.encode64() + + @impl true + def render(_assigns) do + """ + + + Minimal + + +

    Minimal App

    No CSP, no permissions, no mount override. MCP App JS SDK fails to load

    + + """ + end +end diff --git a/test/support/app/mcp/router.ex b/test/support/app/mcp/router.ex index a7f26d3..4235aff 100644 --- a/test/support/app/mcp/router.ex +++ b/test/support/app/mcp/router.ex @@ -29,6 +29,15 @@ defmodule Test.MCP.Router do require Phantom.Prompt, as: Prompt require Phantom.Resource, as: Resource + @description "A sample interactive MCP App exercising all CSP and permission options." + tool :sample_app, app: Test.MCP.SampleApp + + @description "Minimal app with no plugs, no CSP, no mount override." + tool :minimal_app, app: Test.MCP.MinimalApp + + def sample_app(_params, session), do: {:reply, Tool.text("Opened sample app"), session} + def minimal_app(_params, session), do: {:reply, Tool.text("Opened minimal app"), session} + def connect(session, _conn) do {:ok, session} end diff --git a/test/support/app/mcp/sample_app.ex b/test/support/app/mcp/sample_app.ex new file mode 100644 index 0000000..5692f21 --- /dev/null +++ b/test/support/app/mcp/sample_app.ex @@ -0,0 +1,221 @@ +defmodule Test.MCP.SampleApp do + @moduledoc "A sample MCP App using Phoenix layouts, components, and all CSP options." + use Phantom.App, + domain: "localhost", + prefers_border: false + + use Phoenix.Component + import Phoenix.Controller, only: [put_root_layout: 2, put_layout: 2] + + plug :put_root_layout, html: {Test.MCP.Layouts, :root} + plug :put_layout, html: {Test.MCP.Layouts, :app} + + plug Phantom.App.CSP, + connect_domains: + Enum.flat_map([9832, 4000, 4001, 4002], fn port -> + ["http://localhost:#{port}", "ws://localhost:#{port}"] + end), + resource_domains: + Enum.flat_map([9832, 4000, 4001, 4002], fn port -> ["http://localhost:#{port}"] end), + frame_domains: + Enum.flat_map([9832, 4000, 4001, 4002], fn port -> ["http://localhost:#{port}"] end), + base_uri_domains: + Enum.flat_map([9832, 4000, 4001, 4002], fn port -> ["http://localhost:#{port}"] end) + + @impl Phantom.App + def mount(params, session) do + {:ok, + %{ + title: "Sample MCP App", + session_id: session.id, + params: params, + items: ["Alpha", "Bravo", "Charlie"], + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + end + + @impl Phantom.App + def render(assigns) do + ~H""" +

    {@title}

    +
    + Session: {@session_id} -Rendered: {@timestamp} +
    + + <.items_section items={@items} /> + <.params_section params={@params} /> + <.permissions_section /> + <.csp_section /> + <.interactive_section session_id={@session_id} /> + """ + end + + defp items_section(assigns) do + ~H""" + <.section title="Items"> +
      +
    • + {item} +
    • +
    + + """ + end + + defp params_section(assigns) do + ~H""" + <.section title="Params"> +
    +        {JSON.encode!(@params)}
    +      
    + + """ + end + + defp permissions_section(assigns) do + ~H""" + <.section title="Permissions Requested"> + <.badge :for={perm <- ~w[camera microphone geolocation clipboard-write]}>{perm} + + """ + end + + defp csp_section(assigns) do + ~H""" + <.section title="CSP Domains"> +
      +
    • + {label}: {domains} +
    • +
    + + """ + end + + defp interactive_section(assigns) do + ~H""" + <.section title="Interactive"> +
    + + <.button onclick="callEchoTool()">Call echo_tool + <.button kind="secondary" onclick="listResources()">List Resources + <.button kind="secondary" onclick="testClipboard()">Write Clipboard +
    +
    +
    + + + """ + end + + # -- Shared components -- + + defp section(assigns) do + ~H""" +
    +

    + {@title} +

    + {render_slot(@inner_block)} +
    + """ + end + + attr :kind, :string, default: "primary" + attr :rest, :global + slot :inner_block, required: true + + defp button(assigns) do + ~H""" + + """ + end + + defp badge(assigns) do + ~H""" + + {render_slot(@inner_block)} + + """ + end +end diff --git a/test/support/app/router.ex b/test/support/app/router.ex index a7371e9..0620e07 100644 --- a/test/support/app/router.ex +++ b/test/support/app/router.ex @@ -14,4 +14,21 @@ defmodule Test.Router do pubsub: Test.PubSub, validate_origin: false end + + forward "/mcp-apps", Phantom.App.Preview, + router: Test.MCP.Router, + mcp_endpoint: "/mcp" + + get "/*path", Test.FallbackPlug, :index +end + +defmodule Test.FallbackPlug do + use Plug.Builder + import Plug.Conn + + def init(_), do: [] + + def call(conn, _opts) do + conn |> send_resp(404, "Not found") |> halt() + end end diff --git a/test/support/app/stdio.ex b/test/support/app/stdio.ex index 1d3f5ad..62ab246 100644 --- a/test/support/app/stdio.ex +++ b/test/support/app/stdio.ex @@ -13,6 +13,7 @@ defmodule Test.Stdio do }) Application.ensure_all_started(:telemetry) + Application.ensure_all_started(:plug) {:ok, _} = Supervisor.start_link( diff --git a/test/support/cluster.ex b/test/support/cluster.ex index 2ce536b..c8cfb67 100644 --- a/test/support/cluster.ex +++ b/test/support/cluster.ex @@ -52,12 +52,18 @@ defmodule Phantom.Test.Cluster do end end + # Dev/build tools that are loaded into code paths (via `runtime: false` + # deps) but should never be started on a remote test node — starting them + # either no-ops or emits noisy warnings (see dialyxir's start function). + @skip_apps ~w[dialyxir erlex ex_doc earmark_parser makeup makeup_elixir makeup_erlang makeup_javascript]a + @dialyzer {:nowarn_function, ensure_applications_started: 1} defp ensure_applications_started(node) do rpc(node, Application, :ensure_all_started, [:mix]) rpc(node, Mix, :env, [Mix.env()]) - for {app_name, _, _} <- Application.loaded_applications() do + for {app_name, _, _} <- Application.loaded_applications(), + app_name not in @skip_apps do rpc(node, Application, :ensure_all_started, [app_name]) end end