diff --git a/AGENTS.md b/AGENTS.md index cd5bd26..6650144 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,14 @@ Welcome! This document helps you navigate the WebMCP Examples repository efficie - **Key file**: `rails/app/javascript/controllers/bookmarks_webmcp_controller.ts` - Stimulus controller with WebMCP tools - **API used**: `navigator.modelContext.registerTool()` in Stimulus +#### Phoenix LiveView (Elixir) +- **[phoenix-liveview/README.md](./phoenix-liveview/README.md)** - Counter + items with server-side state +- **Location**: `/phoenix-liveview` +- **Key files**: + - `lib/webmcp_demo_web/live/counter_live.ex` - LiveView with state management + - `assets/js/app.js` - WebMCP hook registration +- **API used**: `navigator.modelContext.registerTool()` via LiveView hooks + ### Legacy Examples (Deprecated - DO NOT USE) - **[relegated/README.md](./relegated/README.md)** - Old examples using deprecated MCP SDK - **Warning**: These use the legacy `@modelcontextprotocol/sdk` API @@ -46,13 +54,17 @@ Welcome! This document helps you navigate the WebMCP Examples repository efficie ```bash # Navigate to the example -cd vanilla # or react, rails +cd vanilla # or react, rails, phoenix-liveview # Install dependencies -pnpm install +pnpm install # For JS examples +# OR +mix setup # For Phoenix example # Start development server -pnpm dev +pnpm dev # For JS examples +# OR +mix phx.server # For Phoenix example ``` ### Adding a New Example @@ -164,6 +176,12 @@ example-name/ - Controller: `rails/app/javascript/controllers/bookmarks_webmcp_controller.ts` - Config: `rails/vite.config.ts` +**Phoenix LiveView Example:** +- Entry: `phoenix-liveview/lib/webmcp_demo/application.ex` +- LiveView: `phoenix-liveview/lib/webmcp_demo_web/live/counter_live.ex` +- WebMCP Hook: `phoenix-liveview/assets/js/app.js` +- Config: `phoenix-liveview/config/config.exs` + ## WebMCP Package Documentation - **[@mcp-b/global](https://docs.mcp-b.ai/packages/global)** - Core WebMCP polyfill for vanilla JS @@ -190,7 +208,8 @@ example-name/ ## Prerequisites - **Node.js**: 18 or higher (see `.nvmrc`) -- **pnpm**: Package manager +- **pnpm**: Package manager (for JS examples) +- **Elixir**: 1.14+ (for Phoenix example) - **MCP-B Extension**: Chrome extension for testing WebMCP tools - **Browser**: Chrome or Chromium-based browser diff --git a/README.md b/README.md index 395aa9b..526ba6c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ git clone https://github.com/WebMCP-org/examples.git cd examples # Choose an example -cd vanilla # or react, rails +cd vanilla # or react, rails, phoenix-liveview # Install and run pnpm install @@ -107,6 +107,24 @@ A bookmarks management application demonstrating Rails 7+ integration with Stimu --- +### Phoenix LiveView Example + +**Location:** `/phoenix-liveview` + +A counter and item management app demonstrating WebMCP integration with Phoenix LiveView and server-side state. + +**Features:** +- Uses `navigator.modelContext.registerTool()` via LiveView hooks +- Server-side state management with real-time sync +- Bidirectional communication: AI -> JavaScript -> LiveView -> Server +- 6 AI-callable tools (counter operations + item CRUD + state query) + +**Tech:** Elixir, Phoenix 1.7, LiveView 1.0, `@mcp-b/global` + +[→ Documentation](./phoenix-liveview/README.md) + +--- + ### Legacy Examples (Deprecated) **Location:** `/relegated` @@ -223,7 +241,7 @@ WebMCP enables AI assistants to interact with websites through APIs instead of s ```bash # Development (per example) -cd vanilla # or react, rails +cd vanilla # or react, rails, phoenix-liveview pnpm dev # Run development server pnpm build # Build for production pnpm preview # Preview production build @@ -243,15 +261,17 @@ pnpm preview # Preview production build - [Vanilla Example](./vanilla/README.md) - Vanilla JavaScript implementation - [React Example](./react/README.md) - React with hooks implementation - [Rails Example](./rails/README.md) - Rails with Stimulus controllers +- [Phoenix LiveView Example](./phoenix-liveview/README.md) - Elixir/Phoenix implementation - [Legacy Examples](./relegated/README.md) - Deprecated implementations ## Tech Stack -- **Package Manager:** pnpm -- **Build Tool:** Vite 6 -- **Language:** TypeScript 5.6 +- **Package Manager:** pnpm (JS), Mix (Elixir) +- **Build Tool:** Vite 6, esbuild +- **Languages:** TypeScript 5.6, Elixir 1.14+ - **WebMCP Core:** @mcp-b/global - **React Integration:** @mcp-b/react-webmcp +- **Phoenix Integration:** LiveView hooks + @mcp-b/global - **Validation:** JSON Schema, Zod ## Contributing diff --git a/phoenix-liveview/.formatter.exs b/phoenix-liveview/.formatter.exs new file mode 100644 index 0000000..e945e12 --- /dev/null +++ b/phoenix-liveview/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/phoenix-liveview/.gitignore b/phoenix-liveview/.gitignore new file mode 100644 index 0000000..73a161e --- /dev/null +++ b/phoenix-liveview/.gitignore @@ -0,0 +1,27 @@ +# Elixir/Phoenix +/_build/ +/deps/ +/.fetch +erl_crash.dump +*.ez +*.beam +/config/*.secret.exs +.elixir_ls/ + +# Assets +/assets/node_modules/ +/priv/static/assets/ + +# Generated +/priv/static/cache_manifest.json + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/phoenix-liveview/.nvmrc b/phoenix-liveview/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/phoenix-liveview/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/phoenix-liveview/README.md b/phoenix-liveview/README.md new file mode 100644 index 0000000..67e9c4c --- /dev/null +++ b/phoenix-liveview/README.md @@ -0,0 +1,260 @@ +# Phoenix LiveView WebMCP Example + +A minimal Phoenix LiveView application demonstrating **WebMCP integration** with server-side state management. + +## What This Demonstrates + +This example shows how to integrate WebMCP with Phoenix LiveView using JavaScript hooks: + +- WebMCP tools registered via LiveView hooks +- Bidirectional communication: AI -> JavaScript -> LiveView -> Server +- Real-time state synchronization +- Server-side state management with client-side AI access + +## Quick Start + +### Prerequisites + +- Elixir 1.14+ and Erlang/OTP 25+ +- Node.js 18+ +- A WebMCP-compatible client (browser extension, AI agent, etc.) + +### Installation + +```bash +# Install Elixir dependencies +mix deps.get + +# Install Node.js dependencies and build assets +mix setup + +# Start the development server +mix phx.server +``` + +Open [http://localhost:4000](http://localhost:4000) and connect your WebMCP client to discover the available tools. + +## Available Tools + +This example exposes 6 AI-callable tools: + +| Tool | Description | +|------|-------------| +| `increment_counter` | Increase the counter by 1 | +| `decrement_counter` | Decrease the counter by 1 (minimum 0) | +| `set_counter` | Set the counter to a specific value | +| `add_item` | Add a new item to the list | +| `remove_item` | Remove an item by ID | +| `get_state` | Get current counter and items state | + +## How It Works + +### Architecture + +``` +AI Agent / WebMCP Client + | + v + navigator.modelContext (WebMCP API) + | + v + LiveView Hook (JavaScript) + | + v (pushEvent) + LiveView (Elixir) + | + v + Server State +``` + +### Key Components + +**1. LiveView Hook (`assets/js/app.js`)** + +The WebMCP hook registers tools and communicates with LiveView: + +```javascript +const WebMCPHook = { + mounted() { + // Register tool that calls LiveView + navigator.modelContext.registerTool({ + name: "increment_counter", + description: "Increase the counter by 1", + inputSchema: { type: "object", properties: {} }, + async execute() { + this.pushEvent("increment", {}); + return { + content: [{ type: "text", text: "Counter incremented" }] + }; + } + }); + } +}; +``` + +**2. LiveView (`lib/webmcp_demo_web/live/counter_live.ex`)** + +Handles events from the hook and manages state: + +```elixir +def handle_event("increment", _params, socket) do + new_count = socket.assigns.count + 1 + {:noreply, assign(socket, :count, new_count)} +end +``` + +**3. Template** + +Connects the hook to the LiveView element: + +```heex +
+ +
+``` + +## Project Structure + +``` +phoenix-liveview/ +├── assets/ +│ ├── js/ +│ │ └── app.js # WebMCP hook + LiveSocket setup +│ ├── css/ +│ │ └── app.css # Styles +│ ├── package.json # JS dependencies (@mcp-b/global) +│ └── tailwind.config.js +├── config/ # Phoenix configuration +├── lib/ +│ ├── webmcp_demo/ +│ │ └── application.ex # OTP application +│ └── webmcp_demo_web/ +│ ├── components/ # Phoenix components +│ ├── live/ +│ │ └── counter_live.ex # Main LiveView +│ ├── endpoint.ex +│ └── router.ex +├── mix.exs # Elixir dependencies +├── package.json # npm scripts +└── README.md +``` + +## Key Patterns + +### Registering Tools in Hooks + +```javascript +// In your LiveView hook +mounted() { + const self = this; + + navigator.modelContext.registerTool({ + name: "my_tool", + description: "What it does", + inputSchema: { + type: "object", + properties: { + param: { type: "string", description: "Parameter" } + }, + required: ["param"] + }, + async execute(args) { + // Push event to LiveView + self.pushEvent("my_event", { value: args.param }); + return { + content: [{ type: "text", text: "Done" }] + }; + } + }); +} +``` + +### Getting Data Back from LiveView + +Use `pushEvent` with a callback for request-response patterns: + +```javascript +async execute() { + return new Promise((resolve) => { + self.pushEvent("get_data", {}, (reply) => { + resolve({ + content: [{ type: "text", text: JSON.stringify(reply) }] + }); + }); + }); +} +``` + +With the LiveView handler using `{:reply, ...}`: + +```elixir +def handle_event("get_data", _params, socket) do + {:reply, {:ok, socket.assigns.data}, socket} +end +``` + +### Tool Cleanup + +Always clean up tools when the hook is destroyed: + +```javascript +const WebMCPHook = { + cleanupFns: [], + + mounted() { + const cleanup = navigator.modelContext.registerTool({...}); + this.cleanupFns.push(cleanup); + }, + + destroyed() { + this.cleanupFns.forEach(fn => fn()); + this.cleanupFns = []; + } +}; +``` + +## Comparison: Phoenix vs Other Frameworks + +| Feature | Phoenix LiveView | React | Vanilla JS | +|---------|-----------------|-------|------------| +| State Location | Server | Client | Client | +| State Sync | WebSocket (automatic) | Manual | Manual | +| Tool Registration | LiveView Hook | useWebMCP hook | Direct API | +| Real-time Updates | Built-in | Manual | Manual | +| SEO | Excellent | Requires SSR | N/A | + +## Development + +```bash +# Start development server with live reload +mix phx.server + +# Or run inside IEx for debugging +iex -S mix phx.server + +# Format code +mix format + +# Check types (if using dialyzer) +mix dialyzer +``` + +## Learn More + +### WebMCP +- [WebMCP Documentation](https://docs.mcp-b.ai) +- [WebMCP Quick Start](https://docs.mcp-b.ai/quickstart) +- [@mcp-b/global Package](https://docs.mcp-b.ai/packages/global) + +### Phoenix LiveView +- [Phoenix LiveView Docs](https://hexdocs.pm/phoenix_live_view) +- [LiveView JavaScript Interop](https://hexdocs.pm/phoenix_live_view/js-interop.html) +- [Phoenix Framework](https://phoenixframework.org) + +### Model Context Protocol +- [MCP Documentation](https://modelcontextprotocol.io/) +- [MCP Specification](https://spec.modelcontextprotocol.io/) + +## License + +MIT diff --git a/phoenix-liveview/assets/css/app.css b/phoenix-liveview/assets/css/app.css new file mode 100644 index 0000000..904754a --- /dev/null +++ b/phoenix-liveview/assets/css/app.css @@ -0,0 +1,338 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* Base styles */ +:root { + --color-primary: #4f46e5; + --color-primary-dark: #4338ca; + --color-success: #10b981; + --color-error: #ef4444; + --color-warning: #f59e0b; + --color-bg: #0f172a; + --color-surface: #1e293b; + --color-text: #f8fafc; + --color-text-muted: #94a3b8; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + min-height: 100vh; +} + +/* App container */ +.app { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 2rem; +} + +.header h1 { + font-size: 2.5rem; + font-weight: 700; + background: linear-gradient(135deg, #818cf8, #c084fc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; +} + +.subtitle { + color: var(--color-text-muted); + font-size: 1.1rem; +} + +/* Notifications */ +.notifications { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.notification { + padding: 0.75rem 1rem; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-width: 250px; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.notification-success { + background: var(--color-success); + color: white; +} + +.notification-error { + background: var(--color-error); + color: white; +} + +.notification-info { + background: var(--color-primary); + color: white; +} + +.notification-warning { + background: var(--color-warning); + color: black; +} + +.dismiss-btn { + background: transparent; + border: none; + color: inherit; + font-size: 1.25rem; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; +} + +.dismiss-btn:hover { + opacity: 1; +} + +/* Content layout */ +.content { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Info section */ +.info-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.info-card, +.tools-card { + background: var(--color-surface); + border-radius: 1rem; + padding: 1.5rem; +} + +.info-card h2, +.tools-card h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + color: var(--color-text); +} + +.info-card ul, +.tools-card ul { + list-style: none; + padding-left: 0; +} + +.info-card li, +.tools-card li { + padding: 0.5rem 0; + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.info-card li::before { + content: ">"; + color: var(--color-primary); +} + +.tools-card code { + background: rgba(79, 70, 229, 0.2); + color: #a5b4fc; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; +} + +/* Cards */ +.card { + background: var(--color-surface); + border-radius: 1rem; + padding: 1.5rem; +} + +.card h2 { + font-size: 1.25rem; + margin-bottom: 1rem; +} + +/* Counter section */ +.counter-section { + display: flex; + justify-content: center; +} + +.counter-card { + text-align: center; + min-width: 300px; +} + +.counter-display { + margin: 2rem 0; +} + +.count { + font-size: 5rem; + font-weight: 700; + background: linear-gradient(135deg, #818cf8, #c084fc); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.counter-controls { + display: flex; + justify-content: center; + gap: 1rem; +} + +.last-action { + margin-top: 1rem; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +/* Items section */ +.items-card { + max-width: 600px; + margin: 0 auto; + width: 100%; +} + +.empty-state { + text-align: center; + color: var(--color-text-muted); + padding: 2rem; +} + +.items-list { + list-style: none; + margin-bottom: 1rem; +} + +.item { + display: flex; + align-items: center; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 0.5rem; + margin-bottom: 0.5rem; +} + +.item-name { + flex: 1; + font-weight: 500; +} + +.item-id { + color: var(--color-text-muted); + font-size: 0.75rem; + margin-right: 1rem; +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background: var(--color-primary-dark); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: var(--color-text); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); +} + +.btn-danger { + background: var(--color-error); + color: white; +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-small { + padding: 0.375rem 0.75rem; + font-size: 0.875rem; +} + +/* Footer */ +.footer { + text-align: center; + margin-top: 3rem; + padding-top: 2rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: var(--color-text-muted); +} + +.footer a { + color: var(--color-primary); + text-decoration: none; +} + +.footer a:hover { + text-decoration: underline; +} + +/* Phoenix LiveView specific */ +.phx-click-loading { + opacity: 0.5; + pointer-events: none; +} + +.phx-modal { + opacity: 1 !important; +} diff --git a/phoenix-liveview/assets/js/app.js b/phoenix-liveview/assets/js/app.js new file mode 100644 index 0000000..81a1c2b --- /dev/null +++ b/phoenix-liveview/assets/js/app.js @@ -0,0 +1,164 @@ +/** + * Phoenix LiveView + WebMCP Integration + * + * Demonstrates integrating WebMCP with Phoenix LiveView using JavaScript hooks. + * Tools registered here expose server-side LiveView state to AI agents. + * + * @see https://docs.mcp-b.ai/packages/global - WebMCP global polyfill + * @see https://hexdocs.pm/phoenix_live_view/js-interop.html - LiveView JS interop + */ + +import "@mcp-b/global"; +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; + +/** + * WebMCP Hook for Phoenix LiveView + * + * Registers WebMCP tools on mount, cleans up on destroy. + * Each tool uses `this.pushEvent()` to communicate with the LiveView process. + */ +const WebMCPHook = { + /** @type {Array<() => void>} Cleanup functions for registered tools */ + cleanupFns: [], + + mounted() { + this.registerTools(); + }, + + destroyed() { + this.cleanupFns.forEach((fn) => fn()); + this.cleanupFns = []; + }, + + /** + * Register all WebMCP tools. + * Each tool calls `this.pushEvent()` to send events to LiveView. + */ + registerTools() { + const hook = this; + + // Counter tools + this.registerTool({ + name: "increment_counter", + description: "Increase the counter by 1", + inputSchema: { type: "object", properties: {} }, + async execute() { + hook.pushEvent("increment", {}); + return { content: [{ type: "text", text: "Counter incremented" }] }; + }, + }); + + this.registerTool({ + name: "decrement_counter", + description: "Decrease the counter by 1 (minimum 0)", + inputSchema: { type: "object", properties: {} }, + async execute() { + hook.pushEvent("decrement", {}); + return { content: [{ type: "text", text: "Counter decremented" }] }; + }, + }); + + this.registerTool({ + name: "set_counter", + description: "Set the counter to a specific value", + inputSchema: { + type: "object", + properties: { + value: { type: "number", description: "Value to set (must be >= 0)" }, + }, + required: ["value"], + }, + async execute({ value }) { + if (value < 0) { + return { content: [{ type: "text", text: "Error: Value must be >= 0" }] }; + } + hook.pushEvent("set_count", { value }); + return { content: [{ type: "text", text: `Counter set to ${value}` }] }; + }, + }); + + // Item management tools + this.registerTool({ + name: "add_item", + description: "Add a new item to the list", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Name of the item to add" }, + }, + required: ["name"], + }, + async execute({ name }) { + if (!name?.trim()) { + return { content: [{ type: "text", text: "Error: Name required" }] }; + } + hook.pushEvent("add_item", { name: name.trim() }); + return { content: [{ type: "text", text: `Added: ${name}` }] }; + }, + }); + + this.registerTool({ + name: "remove_item", + description: "Remove an item from the list by its ID", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "ID of the item to remove" }, + }, + required: ["id"], + }, + async execute({ id }) { + hook.pushEvent("remove_item", { id }); + return { content: [{ type: "text", text: `Removed item ${id}` }] }; + }, + }); + + // State query tool (uses reply callback for synchronous response) + this.registerTool({ + name: "get_state", + description: "Get current counter value and all items", + inputSchema: { type: "object", properties: {} }, + async execute() { + return new Promise((resolve) => { + hook.pushEvent("get_state", {}, (reply) => { + if (reply?.count !== undefined) { + const { count, items, item_count, last_action } = reply; + const itemList = items.length + ? items.map((i) => ` - ${i.name} (ID: ${i.id})`).join("\n") + : " (none)"; + resolve({ + content: [{ + type: "text", + text: `Counter: ${count}\nItems (${item_count}):\n${itemList}\nLast action: ${last_action || "none"}`, + }], + }); + } else { + resolve({ content: [{ type: "text", text: "Error fetching state" }] }); + } + }); + }); + }, + }); + }, + + /** Register a tool and track cleanup function */ + registerTool(config) { + const cleanup = navigator.modelContext.registerTool(config); + if (cleanup) this.cleanupFns.push(cleanup); + }, +}; + +// Hooks registry - add WebMCP hook for LiveView elements with phx-hook="WebMCP" +const Hooks = { WebMCP: WebMCPHook }; + +// Initialize Phoenix LiveSocket with WebMCP hooks +const csrfToken = document.querySelector("meta[name='csrf-token']").content; +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: { _csrf_token: csrfToken }, + hooks: Hooks, +}); + +liveSocket.connect(); +window.liveSocket = liveSocket; diff --git a/phoenix-liveview/assets/package.json b/phoenix-liveview/assets/package.json new file mode 100644 index 0000000..59c6b07 --- /dev/null +++ b/phoenix-liveview/assets/package.json @@ -0,0 +1,9 @@ +{ + "name": "phoenix-liveview-webmcp-assets", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@mcp-b/global": "latest" + } +} diff --git a/phoenix-liveview/assets/tailwind.config.js b/phoenix-liveview/assets/tailwind.config.js new file mode 100644 index 0000000..5b4af98 --- /dev/null +++ b/phoenix-liveview/assets/tailwind.config.js @@ -0,0 +1,37 @@ +// Tailwind CSS configuration for Phoenix LiveView + WebMCP demo + +const plugin = require("tailwindcss/plugin"); +const fs = require("fs"); +const path = require("path"); + +module.exports = { + content: ["./js/**/*.js", "../lib/webmcp_demo_web/**/*.*ex"], + theme: { + extend: { + colors: { + brand: "#4f46e5", + }, + }, + }, + plugins: [ + require("@tailwindcss/forms"), + plugin(({ addVariant }) => + addVariant("phx-click-loading", [ + ".phx-click-loading&", + ".phx-click-loading &", + ]) + ), + plugin(({ addVariant }) => + addVariant("phx-submit-loading", [ + ".phx-submit-loading&", + ".phx-submit-loading &", + ]) + ), + plugin(({ addVariant }) => + addVariant("phx-change-loading", [ + ".phx-change-loading&", + ".phx-change-loading &", + ]) + ), + ], +}; diff --git a/phoenix-liveview/config/config.exs b/phoenix-liveview/config/config.exs new file mode 100644 index 0000000..b1091ba --- /dev/null +++ b/phoenix-liveview/config/config.exs @@ -0,0 +1,41 @@ +import Config + +config :webmcp_demo, + generators: [timestamp_type: :utc_datetime] + +config :webmcp_demo, WebmcpDemoWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: WebmcpDemoWeb.ErrorHTML, json: WebmcpDemoWeb.ErrorJSON], + layout: false + ], + pubsub_server: WebmcpDemo.PubSub, + live_view: [signing_salt: "webmcp_demo_salt"] + +config :esbuild, + version: "0.17.11", + webmcp_demo: [ + args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +config :tailwind, + version: "3.4.3", + webmcp_demo: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :phoenix, :json_library, Jason + +import_config "#{config_env()}.exs" diff --git a/phoenix-liveview/config/dev.exs b/phoenix-liveview/config/dev.exs new file mode 100644 index 0000000..21ec195 --- /dev/null +++ b/phoenix-liveview/config/dev.exs @@ -0,0 +1,28 @@ +import Config + +config :webmcp_demo, WebmcpDemoWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "webmcp_demo_dev_secret_key_base_that_is_at_least_64_bytes_long_for_security", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:webmcp_demo, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:webmcp_demo, ~w(--watch)]} + ] + +config :webmcp_demo, WebmcpDemoWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"lib/webmcp_demo_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +config :webmcp_demo, dev_routes: true + +config :logger, :console, format: "[$level] $message\n" + +config :phoenix, :stacktrace_depth, 20 + +config :phoenix, :plug_init_mode, :runtime diff --git a/phoenix-liveview/config/prod.exs b/phoenix-liveview/config/prod.exs new file mode 100644 index 0000000..9871c41 --- /dev/null +++ b/phoenix-liveview/config/prod.exs @@ -0,0 +1,6 @@ +import Config + +config :webmcp_demo, WebmcpDemoWeb.Endpoint, + cache_static_manifest: "priv/static/cache_manifest.json" + +config :logger, level: :info diff --git a/phoenix-liveview/config/runtime.exs b/phoenix-liveview/config/runtime.exs new file mode 100644 index 0000000..e672dd4 --- /dev/null +++ b/phoenix-liveview/config/runtime.exs @@ -0,0 +1,21 @@ +import Config + +if config_env() == :prod do + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :webmcp_demo, WebmcpDemoWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base +end diff --git a/phoenix-liveview/config/test.exs b/phoenix-liveview/config/test.exs new file mode 100644 index 0000000..90d723f --- /dev/null +++ b/phoenix-liveview/config/test.exs @@ -0,0 +1,10 @@ +import Config + +config :webmcp_demo, WebmcpDemoWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "test_secret_key_base_that_is_at_least_64_bytes_long_for_security_purposes", + server: false + +config :logger, level: :warning + +config :phoenix, :plug_init_mode, :runtime diff --git a/phoenix-liveview/lib/webmcp_demo/application.ex b/phoenix-liveview/lib/webmcp_demo/application.ex new file mode 100644 index 0000000..3b871c8 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo/application.ex @@ -0,0 +1,23 @@ +defmodule WebmcpDemo.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + WebmcpDemoWeb.Telemetry, + {Phoenix.PubSub, name: WebmcpDemo.PubSub}, + WebmcpDemoWeb.Endpoint + ] + + opts = [strategy: :one_for_one, name: WebmcpDemo.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + WebmcpDemoWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web.ex b/phoenix-liveview/lib/webmcp_demo_web.ex new file mode 100644 index 0000000..fac07fb --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web.ex @@ -0,0 +1,90 @@ +defmodule WebmcpDemoWeb do + @moduledoc """ + The entrypoint for defining your web interface. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: WebmcpDemoWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {WebmcpDemoWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + import WebmcpDemoWeb.CoreComponents + + alias Phoenix.LiveView.JS + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: WebmcpDemoWeb.Endpoint, + router: WebmcpDemoWeb.Router, + statics: WebmcpDemoWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/components/core_components.ex b/phoenix-liveview/lib/webmcp_demo_web/components/core_components.ex new file mode 100644 index 0000000..bebd4c1 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/components/core_components.ex @@ -0,0 +1,50 @@ +defmodule WebmcpDemoWeb.CoreComponents do + @moduledoc """ + Core UI components for the WebMCP demo. + """ + use Phoenix.Component + + @doc """ + Renders a notification message. + """ + attr :type, :atom, default: :info, values: [:info, :success, :warning, :error] + attr :message, :string, required: true + + def notification(assigns) do + ~H""" +
+ <%= @message %> +
+ """ + end + + @doc """ + Renders a button. + """ + attr :type, :string, default: "button" + attr :class, :string, default: "" + attr :rest, :global + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a card component. + """ + attr :class, :string, default: "" + slot :inner_block, required: true + + def card(assigns) do + ~H""" +
+ <%= render_slot(@inner_block) %> +
+ """ + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/components/layouts.ex b/phoenix-liveview/lib/webmcp_demo_web/components/layouts.ex new file mode 100644 index 0000000..0bfb84c --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/components/layouts.ex @@ -0,0 +1,8 @@ +defmodule WebmcpDemoWeb.Layouts do + @moduledoc """ + Layout components for the WebMCP demo application. + """ + use WebmcpDemoWeb, :html + + embed_templates "layouts/*" +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/components/layouts/app.html.heex b/phoenix-liveview/lib/webmcp_demo_web/components/layouts/app.html.heex new file mode 100644 index 0000000..c753bc6 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/components/layouts/app.html.heex @@ -0,0 +1,3 @@ +
+ <%= @inner_content %> +
diff --git a/phoenix-liveview/lib/webmcp_demo_web/components/layouts/root.html.heex b/phoenix-liveview/lib/webmcp_demo_web/components/layouts/root.html.heex new file mode 100644 index 0000000..7f527f4 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/components/layouts/root.html.heex @@ -0,0 +1,14 @@ + + + + + + + <.live_title>WebMCP LiveView Demo + + + + + <%= @inner_content %> + + diff --git a/phoenix-liveview/lib/webmcp_demo_web/controllers/error_html.ex b/phoenix-liveview/lib/webmcp_demo_web/controllers/error_html.ex new file mode 100644 index 0000000..4d0123d --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/controllers/error_html.ex @@ -0,0 +1,10 @@ +defmodule WebmcpDemoWeb.ErrorHTML do + @moduledoc """ + Error HTML templates. + """ + use WebmcpDemoWeb, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/controllers/error_json.ex b/phoenix-liveview/lib/webmcp_demo_web/controllers/error_json.ex new file mode 100644 index 0000000..69e0640 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/controllers/error_json.ex @@ -0,0 +1,9 @@ +defmodule WebmcpDemoWeb.ErrorJSON do + @moduledoc """ + Error JSON responses. + """ + + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/endpoint.ex b/phoenix-liveview/lib/webmcp_demo_web/endpoint.ex new file mode 100644 index 0000000..0a2b312 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/endpoint.ex @@ -0,0 +1,39 @@ +defmodule WebmcpDemoWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :webmcp_demo + + @session_options [ + store: :cookie, + key: "_webmcp_demo_key", + signing_salt: "webmcp_demo", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :webmcp_demo, + gzip: false, + only: WebmcpDemoWeb.static_paths() + + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug WebmcpDemoWeb.Router +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/live/counter_live.ex b/phoenix-liveview/lib/webmcp_demo_web/live/counter_live.ex new file mode 100644 index 0000000..804d2ce --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/live/counter_live.ex @@ -0,0 +1,223 @@ +defmodule WebmcpDemoWeb.CounterLive do + @moduledoc """ + LiveView demonstrating WebMCP integration with Phoenix. + + This LiveView exposes server-side state to AI agents through WebMCP tools. + The JavaScript hook registers tools that communicate with LiveView via events. + """ + use WebmcpDemoWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:count, 0) + |> assign(:items, []) + |> assign(:notifications, []) + |> assign(:last_action, nil)} + end + + @impl true + def handle_event("increment", _params, socket) do + new_count = socket.assigns.count + 1 + {:noreply, socket |> assign(:count, new_count) |> assign(:last_action, "incremented")} + end + + @impl true + def handle_event("decrement", _params, socket) do + new_count = max(0, socket.assigns.count - 1) + {:noreply, socket |> assign(:count, new_count) |> assign(:last_action, "decremented")} + end + + @impl true + def handle_event("set_count", %{"value" => value}, socket) do + case Integer.parse(to_string(value)) do + {num, _} when num >= 0 -> + {:noreply, socket |> assign(:count, num) |> assign(:last_action, "set to #{num}")} + + _ -> + {:noreply, push_notification(socket, :error, "Invalid count value")} + end + end + + @impl true + def handle_event("add_item", %{"name" => name}, socket) when byte_size(name) > 0 do + item = %{ + id: System.unique_integer([:positive]), + name: name, + created_at: DateTime.utc_now() + } + + items = socket.assigns.items ++ [item] + + {:noreply, + socket + |> assign(:items, items) + |> assign(:last_action, "added item: #{name}") + |> push_notification(:success, "Added: #{name}")} + end + + def handle_event("add_item", _params, socket) do + {:noreply, push_notification(socket, :error, "Item name cannot be empty")} + end + + @impl true + def handle_event("remove_item", %{"id" => id}, socket) do + id = if is_binary(id), do: String.to_integer(id), else: id + {removed, items} = Enum.split_with(socket.assigns.items, &(&1.id == id)) + + case removed do + [item] -> + {:noreply, + socket + |> assign(:items, items) + |> assign(:last_action, "removed item: #{item.name}") + |> push_notification(:success, "Removed: #{item.name}")} + + [] -> + {:noreply, push_notification(socket, :error, "Item not found")} + end + end + + @impl true + def handle_event("clear_items", _params, socket) do + count = length(socket.assigns.items) + + {:noreply, + socket + |> assign(:items, []) + |> assign(:last_action, "cleared #{count} items") + |> push_notification(:success, "Cleared #{count} items")} + end + + @impl true + def handle_event("dismiss_notification", %{"index" => index}, socket) do + index = if is_binary(index), do: String.to_integer(index), else: index + notifications = List.delete_at(socket.assigns.notifications, index) + {:noreply, assign(socket, :notifications, notifications)} + end + + @impl true + def handle_event("get_state", _params, socket) do + state = %{ + count: socket.assigns.count, + items: Enum.map(socket.assigns.items, &Map.take(&1, [:id, :name])), + item_count: length(socket.assigns.items), + last_action: socket.assigns.last_action + } + + {:reply, state, socket} + end + + defp push_notification(socket, type, message) do + notification = %{type: type, message: message, id: System.unique_integer([:positive])} + notifications = socket.assigns.notifications ++ [notification] + assign(socket, :notifications, notifications) + end + + @impl true + def render(assigns) do + ~H""" +
+
+

Phoenix LiveView + WebMCP

+

AI-powered server-side state management

+
+ +
+ <%= for {notification, index} <- Enum.with_index(@notifications) do %> +
+ <%= notification.message %> + +
+ <% end %> +
+ +
+
+
+

How This Works

+

+ This app exposes server-side state to AI agents via WebMCP: +

+
    +
  • Connect any WebMCP-compatible client
  • +
  • Discover 6 tools for counter and item management
  • +
  • AI controls trigger LiveView events
  • +
  • Server state syncs to UI in real-time
  • +
+
+ +
+

Available Tools

+
    +
  • increment_counter - Increase count by 1
  • +
  • decrement_counter - Decrease count by 1
  • +
  • set_counter - Set count to specific value
  • +
  • add_item - Add item to list
  • +
  • remove_item - Remove item by ID
  • +
  • get_state - Get current state
  • +
+
+
+ +
+
+

Counter

+
+ <%= @count %> +
+
+ + +
+ <%= if @last_action do %> +

Last action: <%= @last_action %>

+ <% end %> +
+
+ +
+
+

Items (<%= length(@items) %>)

+ <%= if Enum.empty?(@items) do %> +

No items yet. Use add_item tool to add some.

+ <% else %> +
    + <%= for item <- @items do %> +
  • + <%= item.name %> + #<%= item.id %> + +
  • + <% end %> +
+ + <% end %> +
+
+
+ + +
+ """ + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/router.ex b/phoenix-liveview/lib/webmcp_demo_web/router.ex new file mode 100644 index 0000000..46de753 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/router.ex @@ -0,0 +1,18 @@ +defmodule WebmcpDemoWeb.Router do + use WebmcpDemoWeb, :router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {WebmcpDemoWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + scope "/", WebmcpDemoWeb do + pipe_through :browser + + live "/", CounterLive, :index + end +end diff --git a/phoenix-liveview/lib/webmcp_demo_web/telemetry.ex b/phoenix-liveview/lib/webmcp_demo_web/telemetry.ex new file mode 100644 index 0000000..6b33199 --- /dev/null +++ b/phoenix-liveview/lib/webmcp_demo_web/telemetry.ex @@ -0,0 +1,56 @@ +defmodule WebmcpDemoWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.live_view.mount.stop.duration", + unit: {:native, :millisecond}, + tags: [:view] + ), + summary("phoenix.live_view.handle_params.stop.duration", + unit: {:native, :millisecond}, + tags: [:view] + ), + summary("phoenix.live_view.handle_event.stop.duration", + unit: {:native, :millisecond}, + tags: [:view, :event] + ) + ] + end + + defp periodic_measurements do + [ + {__MODULE__, :measure_memory, []} + ] + end + + def measure_memory do + :telemetry.execute([:webmcp_demo, :memory], %{ + total: :erlang.memory(:total) + }) + end +end diff --git a/phoenix-liveview/mix.exs b/phoenix-liveview/mix.exs new file mode 100644 index 0000000..0a5170c --- /dev/null +++ b/phoenix-liveview/mix.exs @@ -0,0 +1,55 @@ +defmodule WebmcpDemo.MixProject do + use Mix.Project + + def project do + [ + app: :webmcp_demo, + version: "1.0.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + def application do + [ + mod: {WebmcpDemo.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:phoenix, "~> 1.7.14"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.0.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:plug_cowboy, "~> 2.7"}, + {:bandit, "~> 1.5"} + ] + end + + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing", "cmd npm install --prefix assets"], + "assets.build": ["tailwind webmcp_demo", "esbuild webmcp_demo"], + "assets.deploy": [ + "tailwind webmcp_demo --minify", + "esbuild webmcp_demo --minify", + "phx.digest" + ] + ] + end +end diff --git a/phoenix-liveview/package.json b/phoenix-liveview/package.json new file mode 100644 index 0000000..43d6cd1 --- /dev/null +++ b/phoenix-liveview/package.json @@ -0,0 +1,14 @@ +{ + "name": "phoenix-liveview-webmcp-example", + "version": "1.0.0", + "description": "Phoenix LiveView example using the modern WebMCP API", + "type": "module", + "scripts": { + "dev": "cd assets && npm install && cd .. && mix setup && mix phx.server", + "build": "mix assets.deploy", + "typecheck": "echo 'Elixir uses dialyzer for type checking'", + "lint": "mix format --check-formatted" + }, + "keywords": ["phoenix", "liveview", "webmcp", "mcp", "elixir"], + "license": "MIT" +}