Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ Thumbs.db
/test/pro-test-suite.js
/src/extensionsIntegrated/phoenix-pro

# ignore node_modules inside phoenix-builder-mcp
/phoenix-builder-mcp/node_modules

# ignore MCP server runtime files
/phoenix-builder-mcp/.mcp-server.pid

# ignore chrome extension build artifacts
/phoenix-builder-mcp/chrome_extension/build/
/phoenix-builder-mcp/chrome_extension/*.zip

# ignore node_modules inside src
/src/node_modules
/src-node/node_modules
Expand Down
11 changes: 11 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"phoenix-builder": {
"command": "node",
"args": ["phoenix-builder-mcp/index.js"],
"env": {
"PHOENIX_DESKTOP_PATH": "../phoenix-desktop"
}
}
}
}
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
- Brace style: (`if (x) {`), single-line blocks allowed.
- Always use curly braces for `if`/`else`/`for`/`while`.
- No trailing whitespace.
- Use `const` and `let` instead of `var`.
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"lmdb": "^3.5.1"
},
"scripts": {
"postinstall": "npm install --prefix phoenix-builder-mcp",
"lint": "eslint --quiet src test",
"lint:fix": "eslint --quiet --fix src test",
"prepare": "husky install",
Expand Down
128 changes: 128 additions & 0 deletions phoenix-builder-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Phoenix Builder MCP

An MCP (Model Context Protocol) server that lets Claude Code launch, control, and inspect a running Phoenix Code instance. It also includes a Chrome extension that enables screenshot capture when Phoenix runs in a browser.

## Prerequisites

- Node.js
- The [phoenix-desktop](https://github.com/nicedoc/phoenix-desktop) repo cloned alongside this repo (i.e. `../phoenix-desktop`)

## Setup

### 1. Install dependencies

```bash
cd phoenix-builder-mcp
npm install
```

### 2. Claude Code MCP configuration

The project root already contains `.mcp.json` which registers the server automatically:

```json
{
"mcpServers": {
"phoenix-builder": {
"command": "node",
"args": ["phoenix-builder-mcp/index.js"],
"env": {
"PHOENIX_DESKTOP_PATH": "../phoenix-desktop"
}
}
}
}
```

Set `PHOENIX_DESKTOP_PATH` to the path of your phoenix-desktop checkout if it is not at `../phoenix-desktop`.

You can also set `PHOENIX_MCP_WS_PORT` (default `38571`) to change the WebSocket port used for communication between the MCP server and the Phoenix browser runtime.

### 3. Chrome extension (for browser screenshots)

Screenshots work out of the box in the Electron/Tauri desktop app. If you are running Phoenix in a browser (e.g. `localhost` or `phcode.dev`), you need to install the Chrome extension:

#### Loading as an unpacked extension (development)

1. Open `chrome://extensions` in Chrome.
2. Enable **Developer mode** (toggle in the top-right corner).
3. Click **Load unpacked**.
4. Select the `phoenix-builder-mcp/chrome_extension/` directory.
5. The extension will appear as "Phoenix Code Screenshot".

Once loaded, any Phoenix page on `localhost` or `phcode.dev` will have `window._phoenixScreenshotExtensionAvailable` set to `true`, and the `take_screenshot` MCP tool and `Phoenix.app.screenShotBinary()` API will work in the browser.

#### Building a .zip for distribution

```bash
cd phoenix-builder-mcp/chrome_extension
./build.sh
```

This produces `chrome_extension/build/phoenix-screenshot-extension.zip`.

To build a signed `.crx` you need the Chrome binary and a private key:

```bash
chrome --pack-extension=./phoenix-builder-mcp/chrome_extension --pack-extension-key=key.pem
```

## MCP Tools

Once the MCP server is running, the following tools are available in Claude Code:

### `start_phoenix`
Launches the Phoenix Code Electron app by running `npm run serve:electron` in the phoenix-desktop directory. Returns the process PID and WebSocket port.

### `stop_phoenix`
Stops the running Phoenix Code process (SIGTERM, then SIGKILL after 5s).

### `get_phoenix_status`
Returns process status, PID, WebSocket connection state, connected instance names, and the WS port.

### `get_terminal_logs`
Returns stdout/stderr from the Electron process. By default returns only new logs since the last call. Pass `clear: true` to get all logs and clear the buffer.

### `get_browser_console_logs`
Returns `console.log`/`warn`/`error` output forwarded from the Phoenix browser runtime over WebSocket. Supports the same `clear` flag. When multiple Phoenix instances are connected, pass `instance` to target a specific one (e.g. `"Phoenix-a3f2"`).

### `take_screenshot`
Captures a PNG screenshot of the Phoenix window. Optionally pass a `selector` (CSS selector string) to capture a specific element. Returns the image directly as `image/png`.

In Electron/Tauri this uses the native capture API. In the browser it requires the Chrome extension (see above).

### `reload_phoenix`
Reloads the Phoenix app. Prompts to save unsaved files before reloading.

### `force_reload_phoenix`
Force-reloads the Phoenix app without saving unsaved changes.

## Typical Claude Code workflow

```
> start_phoenix # launches the app
> take_screenshot # see what the UI looks like
> get_browser_console_logs # check for errors
> reload_phoenix # pick up code changes
> take_screenshot # verify the fix
> stop_phoenix # done
```

## Architecture

```
Claude Code <--stdio--> MCP Server (index.js)
|
+-- process-manager.js (spawns/kills Electron)
+-- ws-control-server.js (WebSocket on port 38571)
|
Phoenix browser runtime
(connects back over WS for logs, screenshots, reload)
```

For browser-mode screenshots the flow is:

```
MCP Server --WS--> Phoenix runtime --postMessage--> Content Script --chrome.runtime--> Background SW
(captureVisibleTab)
```
13 changes: 13 additions & 0 deletions phoenix-builder-mcp/chrome_extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== "phoenix_screenshot_capture") {
return false;
}
chrome.tabs.captureVisibleTab(null, { format: "png" })
.then(dataUrl => {
sendResponse({ success: true, dataUrl });
})
.catch(err => {
sendResponse({ success: false, error: err.message || String(err) });
});
return true; // keep channel open for async sendResponse
});
26 changes: 26 additions & 0 deletions phoenix-builder-mcp/chrome_extension/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
# Builds a .zip of the Chrome extension for distribution or local install.
# Usage: ./build.sh
#
# To load as an unpacked extension during development:
# 1. Open chrome://extensions
# 2. Enable "Developer mode"
# 3. Click "Load unpacked" and select this directory
#
# To build a .crx (signed package) you need the Chrome binary and a private key:
# chrome --pack-extension=./phoenix-builder-mcp/chrome_extension --pack-extension-key=key.pem

set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"

rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"

zip -j "$BUILD_DIR/phoenix-screenshot-extension.zip" \
"$SCRIPT_DIR/manifest.json" \
"$SCRIPT_DIR/background.js" \
"$SCRIPT_DIR/content-script.js" \
"$SCRIPT_DIR/page-script.js"

echo "Built: $BUILD_DIR/phoenix-screenshot-extension.zip"
27 changes: 27 additions & 0 deletions phoenix-builder-mcp/chrome_extension/content-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Relay screenshot requests from the page to the background service worker.
// The availability flag (window._phoenixScreenshotExtensionAvailable) is set by
// page-script.js which runs in the MAIN world via the manifest.
window.addEventListener("message", (event) => {
if (event.source !== window || !event.data || event.data.type !== "phoenix_screenshot_request") {
return;
}
const requestId = event.data.id;
chrome.runtime.sendMessage({ type: "phoenix_screenshot_capture" }, (response) => {
if (chrome.runtime.lastError) {
window.postMessage({
type: "phoenix_screenshot_response",
id: requestId,
success: false,
error: chrome.runtime.lastError.message || "Extension communication error"
}, "*");
return;
}
window.postMessage({
type: "phoenix_screenshot_response",
id: requestId,
success: response.success,
dataUrl: response.dataUrl,
error: response.error
}, "*");
});
});
36 changes: 36 additions & 0 deletions phoenix-builder-mcp/chrome_extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "Phoenix Code Screenshot",
"version": "1.0.0",
"description": "Enables screenshot capture in Phoenix Code when running in the browser.",
"permissions": [],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"http://localhost/*",
"https://phcode.dev/*",
"https://*.phcode.dev/*"
],
"js": ["page-script.js"],
"run_at": "document_start",
"all_frames": false,
"world": "MAIN"
},
{
"matches": [
"http://localhost/*",
"https://phcode.dev/*",
"https://*.phcode.dev/*"
],
"js": ["content-script.js"],
"run_at": "document_start",
"all_frames": false
}
]
}
3 changes: 3 additions & 0 deletions phoenix-builder-mcp/chrome_extension/page-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Runs in the MAIN world (the page's own JS context) at document_start,
// so it executes before deferred modules like shell.js.
window._phoenixScreenshotExtensionAvailable = true;
73 changes: 73 additions & 0 deletions phoenix-builder-mcp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createWSControlServer } from "./ws-control-server.js";
import { createProcessManager } from "./process-manager.js";
import { registerTools } from "./mcp-tools.js";
import { fileURLToPath } from "url";
import path from "path";
import fs from "fs";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const PID_FILE = path.join(__dirname, ".mcp-server.pid");

// Kill any previous MCP server instance that wasn't cleaned up (e.g. parent crashed).
try {
const oldPid = parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
if (oldPid && oldPid !== process.pid) {
try {
process.kill(oldPid, "SIGTERM");
// Wait up to 3 seconds for it to exit
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
try {
process.kill(oldPid, 0); // throws if process is gone
await new Promise(r => setTimeout(r, 100));
} catch {
break;
}
}
} catch {
// Process already dead — nothing to do
}
}
} catch {
// No PID file or unreadable — first run
}
fs.writeFileSync(PID_FILE, String(process.pid));

function removePidFile() {
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
}

const wsPort = parseInt(process.env.PHOENIX_MCP_WS_PORT || "38571", 10);
const phoenixDesktopPath = process.env.PHOENIX_DESKTOP_PATH
|| path.resolve(__dirname, "../../phoenix-desktop");

const wsControlServer = createWSControlServer(wsPort);
const processManager = createProcessManager();

const server = new McpServer({
name: "phoenix-builder",
version: "1.0.0"
});

registerTools(server, processManager, wsControlServer, phoenixDesktopPath);

const transport = new StdioServerTransport();
await server.connect(transport);

process.on("SIGINT", async () => {
await processManager.stop();
wsControlServer.close();
removePidFile();
process.exit(0);
});

process.on("SIGTERM", async () => {
await processManager.stop();
wsControlServer.close();
removePidFile();
process.exit(0);
});
Loading
Loading