Skip to content

Commit 382d860

Browse files
committed
feat: chrome extenstion for phoenix builder to take screenshots
1 parent fb6238b commit 382d860

9 files changed

Lines changed: 359 additions & 3 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ Thumbs.db
1818
# ignore node_modules inside phoenix-builder-mcp
1919
/phoenix-builder-mcp/node_modules
2020

21+
# ignore MCP server runtime files
22+
/phoenix-builder-mcp/.mcp-server.pid
23+
24+
# ignore chrome extension build artifacts
25+
/phoenix-builder-mcp/chrome_extension/build/
26+
/phoenix-builder-mcp/chrome_extension/*.zip
27+
2128
# ignore node_modules inside src
2229
/src/node_modules
2330
/src-node/node_modules

phoenix-builder-mcp/README.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Phoenix Builder MCP
2+
3+
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.
4+
5+
## Prerequisites
6+
7+
- Node.js
8+
- The [phoenix-desktop](https://github.com/nicedoc/phoenix-desktop) repo cloned alongside this repo (i.e. `../phoenix-desktop`)
9+
10+
## Setup
11+
12+
### 1. Install dependencies
13+
14+
```bash
15+
cd phoenix-builder-mcp
16+
npm install
17+
```
18+
19+
### 2. Claude Code MCP configuration
20+
21+
The project root already contains `.mcp.json` which registers the server automatically:
22+
23+
```json
24+
{
25+
"mcpServers": {
26+
"phoenix-builder": {
27+
"command": "node",
28+
"args": ["phoenix-builder-mcp/index.js"],
29+
"env": {
30+
"PHOENIX_DESKTOP_PATH": "../phoenix-desktop"
31+
}
32+
}
33+
}
34+
}
35+
```
36+
37+
Set `PHOENIX_DESKTOP_PATH` to the path of your phoenix-desktop checkout if it is not at `../phoenix-desktop`.
38+
39+
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.
40+
41+
### 3. Chrome extension (for browser screenshots)
42+
43+
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:
44+
45+
#### Loading as an unpacked extension (development)
46+
47+
1. Open `chrome://extensions` in Chrome.
48+
2. Enable **Developer mode** (toggle in the top-right corner).
49+
3. Click **Load unpacked**.
50+
4. Select the `phoenix-builder-mcp/chrome_extension/` directory.
51+
5. The extension will appear as "Phoenix Code Screenshot".
52+
53+
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.
54+
55+
#### Building a .zip for distribution
56+
57+
```bash
58+
cd phoenix-builder-mcp/chrome_extension
59+
./build.sh
60+
```
61+
62+
This produces `chrome_extension/build/phoenix-screenshot-extension.zip`.
63+
64+
To build a signed `.crx` you need the Chrome binary and a private key:
65+
66+
```bash
67+
chrome --pack-extension=./phoenix-builder-mcp/chrome_extension --pack-extension-key=key.pem
68+
```
69+
70+
## MCP Tools
71+
72+
Once the MCP server is running, the following tools are available in Claude Code:
73+
74+
### `start_phoenix`
75+
Launches the Phoenix Code Electron app by running `npm run serve:electron` in the phoenix-desktop directory. Returns the process PID and WebSocket port.
76+
77+
### `stop_phoenix`
78+
Stops the running Phoenix Code process (SIGTERM, then SIGKILL after 5s).
79+
80+
### `get_phoenix_status`
81+
Returns process status, PID, WebSocket connection state, connected instance names, and the WS port.
82+
83+
### `get_terminal_logs`
84+
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.
85+
86+
### `get_browser_console_logs`
87+
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"`).
88+
89+
### `take_screenshot`
90+
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`.
91+
92+
In Electron/Tauri this uses the native capture API. In the browser it requires the Chrome extension (see above).
93+
94+
### `reload_phoenix`
95+
Reloads the Phoenix app. Prompts to save unsaved files before reloading.
96+
97+
### `force_reload_phoenix`
98+
Force-reloads the Phoenix app without saving unsaved changes.
99+
100+
## Typical Claude Code workflow
101+
102+
```
103+
> start_phoenix # launches the app
104+
> take_screenshot # see what the UI looks like
105+
> get_browser_console_logs # check for errors
106+
> reload_phoenix # pick up code changes
107+
> take_screenshot # verify the fix
108+
> stop_phoenix # done
109+
```
110+
111+
## Architecture
112+
113+
```
114+
Claude Code <--stdio--> MCP Server (index.js)
115+
|
116+
+-- process-manager.js (spawns/kills Electron)
117+
+-- ws-control-server.js (WebSocket on port 38571)
118+
|
119+
Phoenix browser runtime
120+
(connects back over WS for logs, screenshots, reload)
121+
```
122+
123+
For browser-mode screenshots the flow is:
124+
125+
```
126+
MCP Server --WS--> Phoenix runtime --postMessage--> Content Script --chrome.runtime--> Background SW
127+
(captureVisibleTab)
128+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
2+
if (message.type !== "phoenix_screenshot_capture") {
3+
return false;
4+
}
5+
chrome.tabs.captureVisibleTab(null, { format: "png" })
6+
.then(dataUrl => {
7+
sendResponse({ success: true, dataUrl });
8+
})
9+
.catch(err => {
10+
sendResponse({ success: false, error: err.message || String(err) });
11+
});
12+
return true; // keep channel open for async sendResponse
13+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/bin/bash
2+
# Builds a .zip of the Chrome extension for distribution or local install.
3+
# Usage: ./build.sh
4+
#
5+
# To load as an unpacked extension during development:
6+
# 1. Open chrome://extensions
7+
# 2. Enable "Developer mode"
8+
# 3. Click "Load unpacked" and select this directory
9+
#
10+
# To build a .crx (signed package) you need the Chrome binary and a private key:
11+
# chrome --pack-extension=./phoenix-builder-mcp/chrome_extension --pack-extension-key=key.pem
12+
13+
set -euo pipefail
14+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15+
BUILD_DIR="$SCRIPT_DIR/build"
16+
17+
rm -rf "$BUILD_DIR"
18+
mkdir -p "$BUILD_DIR"
19+
20+
zip -j "$BUILD_DIR/phoenix-screenshot-extension.zip" \
21+
"$SCRIPT_DIR/manifest.json" \
22+
"$SCRIPT_DIR/background.js" \
23+
"$SCRIPT_DIR/content-script.js" \
24+
"$SCRIPT_DIR/page-script.js"
25+
26+
echo "Built: $BUILD_DIR/phoenix-screenshot-extension.zip"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Relay screenshot requests from the page to the background service worker.
2+
// The availability flag (window._phoenixScreenshotExtensionAvailable) is set by
3+
// page-script.js which runs in the MAIN world via the manifest.
4+
window.addEventListener("message", (event) => {
5+
if (event.source !== window || !event.data || event.data.type !== "phoenix_screenshot_request") {
6+
return;
7+
}
8+
const requestId = event.data.id;
9+
chrome.runtime.sendMessage({ type: "phoenix_screenshot_capture" }, (response) => {
10+
if (chrome.runtime.lastError) {
11+
window.postMessage({
12+
type: "phoenix_screenshot_response",
13+
id: requestId,
14+
success: false,
15+
error: chrome.runtime.lastError.message || "Extension communication error"
16+
}, "*");
17+
return;
18+
}
19+
window.postMessage({
20+
type: "phoenix_screenshot_response",
21+
id: requestId,
22+
success: response.success,
23+
dataUrl: response.dataUrl,
24+
error: response.error
25+
}, "*");
26+
});
27+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Phoenix Code Screenshot",
4+
"version": "1.0.0",
5+
"description": "Enables screenshot capture in Phoenix Code when running in the browser.",
6+
"permissions": [],
7+
"host_permissions": [
8+
"<all_urls>"
9+
],
10+
"background": {
11+
"service_worker": "background.js"
12+
},
13+
"content_scripts": [
14+
{
15+
"matches": [
16+
"http://localhost/*",
17+
"https://phcode.dev/*",
18+
"https://*.phcode.dev/*"
19+
],
20+
"js": ["page-script.js"],
21+
"run_at": "document_start",
22+
"all_frames": false,
23+
"world": "MAIN"
24+
},
25+
{
26+
"matches": [
27+
"http://localhost/*",
28+
"https://phcode.dev/*",
29+
"https://*.phcode.dev/*"
30+
],
31+
"js": ["content-script.js"],
32+
"run_at": "document_start",
33+
"all_frames": false
34+
}
35+
]
36+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Runs in the MAIN world (the page's own JS context) at document_start,
2+
// so it executes before deferred modules like shell.js.
3+
window._phoenixScreenshotExtensionAvailable = true;

phoenix-builder-mcp/index.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,42 @@ import { createProcessManager } from "./process-manager.js";
55
import { registerTools } from "./mcp-tools.js";
66
import { fileURLToPath } from "url";
77
import path from "path";
8+
import fs from "fs";
89

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

13+
const PID_FILE = path.join(__dirname, ".mcp-server.pid");
14+
15+
// Kill any previous MCP server instance that wasn't cleaned up (e.g. parent crashed).
16+
try {
17+
const oldPid = parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
18+
if (oldPid && oldPid !== process.pid) {
19+
try {
20+
process.kill(oldPid, "SIGTERM");
21+
// Wait up to 3 seconds for it to exit
22+
const deadline = Date.now() + 3000;
23+
while (Date.now() < deadline) {
24+
try {
25+
process.kill(oldPid, 0); // throws if process is gone
26+
await new Promise(r => setTimeout(r, 100));
27+
} catch {
28+
break;
29+
}
30+
}
31+
} catch {
32+
// Process already dead — nothing to do
33+
}
34+
}
35+
} catch {
36+
// No PID file or unreadable — first run
37+
}
38+
fs.writeFileSync(PID_FILE, String(process.pid));
39+
40+
function removePidFile() {
41+
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
42+
}
43+
1244
const wsPort = parseInt(process.env.PHOENIX_MCP_WS_PORT || "38571", 10);
1345
const phoenixDesktopPath = process.env.PHOENIX_DESKTOP_PATH
1446
|| path.resolve(__dirname, "../../phoenix-desktop");
@@ -29,11 +61,13 @@ await server.connect(transport);
2961
process.on("SIGINT", async () => {
3062
await processManager.stop();
3163
wsControlServer.close();
64+
removePidFile();
3265
process.exit(0);
3366
});
3467

3568
process.on("SIGTERM", async () => {
3669
await processManager.stop();
3770
wsControlServer.close();
71+
removePidFile();
3872
process.exit(0);
3973
});

0 commit comments

Comments
 (0)