Skip to content

Commit 0ce5adf

Browse files
committed
feat: phoenix builder mcp for phoenix to build itself
1 parent b9fa94a commit 0ce5adf

17 files changed

Lines changed: 2429 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Thumbs.db
1515
/test/pro-test-suite.js
1616
/src/extensionsIntegrated/phoenix-pro
1717

18+
# ignore node_modules inside phoenix-builder-mcp
19+
/phoenix-builder-mcp/node_modules
20+
1821
# ignore node_modules inside src
1922
/src/node_modules
2023
/src-node/node_modules

.mcp.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"mcpServers": {
3+
"phoenix-builder": {
4+
"command": "node",
5+
"args": ["phoenix-builder-mcp/index.js"],
6+
"env": {
7+
"PHOENIX_DESKTOP_PATH": "../phoenix-desktop"
8+
}
9+
}
10+
}
11+
}

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"lmdb": "^3.5.1"
4343
},
4444
"scripts": {
45+
"postinstall": "npm install --prefix phoenix-builder-mcp",
4546
"lint": "eslint --quiet src test",
4647
"lint:fix": "eslint --quiet --fix src test",
4748
"prepare": "husky install",

phoenix-builder-mcp/index.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3+
import { createWSControlServer } from "./ws-control-server.js";
4+
import { createProcessManager } from "./process-manager.js";
5+
import { registerTools } from "./mcp-tools.js";
6+
import { fileURLToPath } from "url";
7+
import path from "path";
8+
9+
const __filename = fileURLToPath(import.meta.url);
10+
const __dirname = path.dirname(__filename);
11+
12+
const wsPort = parseInt(process.env.PHOENIX_MCP_WS_PORT || "38571", 10);
13+
const phoenixDesktopPath = process.env.PHOENIX_DESKTOP_PATH
14+
|| path.resolve(__dirname, "../../phoenix-desktop");
15+
16+
const wsControlServer = createWSControlServer(wsPort);
17+
const processManager = createProcessManager();
18+
19+
const server = new McpServer({
20+
name: "phoenix-builder",
21+
version: "1.0.0"
22+
});
23+
24+
registerTools(server, processManager, wsControlServer, phoenixDesktopPath);
25+
26+
const transport = new StdioServerTransport();
27+
await server.connect(transport);
28+
29+
process.on("SIGINT", async () => {
30+
await processManager.stop();
31+
wsControlServer.close();
32+
process.exit(0);
33+
});
34+
35+
process.on("SIGTERM", async () => {
36+
await processManager.stop();
37+
wsControlServer.close();
38+
process.exit(0);
39+
});

phoenix-builder-mcp/log-buffer.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const MAX_ENTRIES = 10000;
2+
3+
export class LogBuffer {
4+
constructor() {
5+
this._entries = [];
6+
this._readIndex = 0;
7+
}
8+
9+
push(entry) {
10+
this._entries.push(entry);
11+
if (this._entries.length > MAX_ENTRIES) {
12+
const overflow = this._entries.length - MAX_ENTRIES;
13+
this._entries.splice(0, overflow);
14+
this._readIndex = Math.max(0, this._readIndex - overflow);
15+
}
16+
}
17+
18+
getAll() {
19+
return this._entries.slice();
20+
}
21+
22+
getSinceLastRead() {
23+
const newEntries = this._entries.slice(this._readIndex);
24+
this._readIndex = this._entries.length;
25+
return newEntries;
26+
}
27+
28+
clear() {
29+
this._entries = [];
30+
this._readIndex = 0;
31+
}
32+
}

phoenix-builder-mcp/mcp-tools.js

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { z } from "zod";
2+
3+
export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) {
4+
server.tool(
5+
"start_phoenix",
6+
"Start the Phoenix Code desktop app (Electron). Launches npm run serve:electron in the phoenix-desktop directory.",
7+
{},
8+
async () => {
9+
try {
10+
if (processManager.isRunning()) {
11+
return {
12+
content: [{
13+
type: "text",
14+
text: JSON.stringify({
15+
success: false,
16+
error: "Phoenix is already running",
17+
pid: processManager.getPid()
18+
})
19+
}]
20+
};
21+
}
22+
const result = await processManager.start(phoenixDesktopPath);
23+
return {
24+
content: [{
25+
type: "text",
26+
text: JSON.stringify({
27+
success: true,
28+
pid: result.pid,
29+
wsPort: wsControlServer.getPort()
30+
})
31+
}]
32+
};
33+
} catch (err) {
34+
return {
35+
content: [{
36+
type: "text",
37+
text: JSON.stringify({ success: false, error: err.message })
38+
}]
39+
};
40+
}
41+
}
42+
);
43+
44+
server.tool(
45+
"stop_phoenix",
46+
"Stop the running Phoenix Code desktop app.",
47+
{},
48+
async () => {
49+
try {
50+
const result = await processManager.stop();
51+
return {
52+
content: [{
53+
type: "text",
54+
text: JSON.stringify(result)
55+
}]
56+
};
57+
} catch (err) {
58+
return {
59+
content: [{
60+
type: "text",
61+
text: JSON.stringify({ success: false, error: err.message })
62+
}]
63+
};
64+
}
65+
}
66+
);
67+
68+
server.tool(
69+
"get_terminal_logs",
70+
"Get stdout/stderr output from the Electron process. By default returns new logs since last call; set clear=true to get all logs and clear the buffer.",
71+
{ clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read.") },
72+
async ({ clear }) => {
73+
let logs;
74+
if (clear) {
75+
logs = processManager.getTerminalLogs(false);
76+
processManager.clearTerminalLogs();
77+
} else {
78+
logs = processManager.getTerminalLogs(true);
79+
}
80+
const text = logs.map(e => `[${e.stream}] ${e.text}`).join("");
81+
return {
82+
content: [{
83+
type: "text",
84+
text: text || "(no terminal logs)"
85+
}]
86+
};
87+
}
88+
);
89+
90+
server.tool(
91+
"get_browser_console_logs",
92+
"Get console logs forwarded from the Phoenix browser runtime via WebSocket. By default returns new logs since last call; set clear=true to get all logs and clear the buffer.",
93+
{
94+
clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read."),
95+
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
96+
},
97+
async ({ clear, instance }) => {
98+
let logs;
99+
if (clear) {
100+
logs = wsControlServer.getBrowserLogs(false, instance);
101+
if (logs && logs.error) {
102+
return {
103+
content: [{ type: "text", text: JSON.stringify(logs) }]
104+
};
105+
}
106+
const clearResult = wsControlServer.clearBrowserLogs(instance);
107+
if (clearResult && clearResult.error) {
108+
return {
109+
content: [{ type: "text", text: JSON.stringify(clearResult) }]
110+
};
111+
}
112+
} else {
113+
logs = wsControlServer.getBrowserLogs(true, instance);
114+
if (logs && logs.error) {
115+
return {
116+
content: [{ type: "text", text: JSON.stringify(logs) }]
117+
};
118+
}
119+
}
120+
return {
121+
content: [{
122+
type: "text",
123+
text: JSON.stringify(logs.length > 0 ? logs : "(no browser logs)")
124+
}]
125+
};
126+
}
127+
);
128+
129+
server.tool(
130+
"take_screenshot",
131+
"Take a screenshot of the Phoenix Code app window. Returns a PNG image.",
132+
{
133+
selector: z.string().optional().describe("Optional CSS selector to capture a specific element"),
134+
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
135+
},
136+
async ({ selector, instance }) => {
137+
try {
138+
const base64Data = await wsControlServer.requestScreenshot(selector, instance);
139+
return {
140+
content: [{
141+
type: "image",
142+
data: base64Data,
143+
mimeType: "image/png"
144+
}]
145+
};
146+
} catch (err) {
147+
return {
148+
content: [{
149+
type: "text",
150+
text: JSON.stringify({ error: err.message })
151+
}]
152+
};
153+
}
154+
}
155+
);
156+
157+
server.tool(
158+
"reload_phoenix",
159+
"Reload the Phoenix Code app. Closes all open files (prompting to save unsaved changes) then reloads the app.",
160+
{
161+
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
162+
},
163+
async ({ instance }) => {
164+
try {
165+
const result = await wsControlServer.requestReload(false, instance);
166+
return {
167+
content: [{
168+
type: "text",
169+
text: JSON.stringify({ success: true, message: "Phoenix is reloading" })
170+
}]
171+
};
172+
} catch (err) {
173+
return {
174+
content: [{
175+
type: "text",
176+
text: JSON.stringify({ error: err.message })
177+
}]
178+
};
179+
}
180+
}
181+
);
182+
183+
server.tool(
184+
"force_reload_phoenix",
185+
"Force reload the Phoenix Code app without saving. Closes all open files without saving unsaved changes, then reloads the app.",
186+
{
187+
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
188+
},
189+
async ({ instance }) => {
190+
try {
191+
const result = await wsControlServer.requestReload(true, instance);
192+
return {
193+
content: [{
194+
type: "text",
195+
text: JSON.stringify({ success: true, message: "Phoenix is force reloading (unsaved changes discarded)" })
196+
}]
197+
};
198+
} catch (err) {
199+
return {
200+
content: [{
201+
type: "text",
202+
text: JSON.stringify({ error: err.message })
203+
}]
204+
};
205+
}
206+
}
207+
);
208+
209+
server.tool(
210+
"get_phoenix_status",
211+
"Check the status of the Phoenix process and WebSocket connection.",
212+
{},
213+
async () => {
214+
return {
215+
content: [{
216+
type: "text",
217+
text: JSON.stringify({
218+
processRunning: processManager.isRunning(),
219+
pid: processManager.getPid(),
220+
wsConnected: wsControlServer.isClientConnected(),
221+
connectedInstances: wsControlServer.getConnectedInstances(),
222+
wsPort: wsControlServer.getPort()
223+
})
224+
}]
225+
};
226+
}
227+
);
228+
}

0 commit comments

Comments
 (0)