Skip to content

Commit f4c25a1

Browse files
committed
feat: phoenix builder mcp server comunication with live preview
1 parent bccdbe5 commit f4c25a1

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

phoenix-builder-mcp/mcp-tools.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,37 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe
340340
}
341341
);
342342

343+
server.tool(
344+
"exec_js_in_live_preview",
345+
"Execute JavaScript in the live preview iframe (the page being previewed), NOT in Phoenix itself. " +
346+
"Auto-opens the live preview panel if it is not already visible. " +
347+
"Code is evaluated via eval() in the global scope of the previewed page. " +
348+
"Note: eval() is synchronous — async/await is NOT supported. " +
349+
"Use this to inspect or manipulate the user's live-previewed web page (e.g. document.title, DOM queries).",
350+
{
351+
code: z.string().describe("JavaScript code to execute in the live preview iframe"),
352+
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
353+
},
354+
async ({ code, instance }) => {
355+
try {
356+
const result = await wsControlServer.requestExecJsLivePreview(code, instance);
357+
return {
358+
content: [{
359+
type: "text",
360+
text: result !== undefined ? String(result) : "(undefined)"
361+
}]
362+
};
363+
} catch (err) {
364+
return {
365+
content: [{
366+
type: "text",
367+
text: JSON.stringify({ error: err.message })
368+
}]
369+
};
370+
}
371+
}
372+
);
373+
343374
server.tool(
344375
"get_phoenix_status",
345376
"Check the status of the Phoenix process and WebSocket connection.",

phoenix-builder-mcp/ws-control-server.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ export function createWSControlServer(port) {
9696
break;
9797
}
9898

99+
case "exec_js_live_preview_response": {
100+
const pending6 = pendingRequests.get(msg.id);
101+
if (pending6) {
102+
pendingRequests.delete(msg.id);
103+
if (msg.error) {
104+
pending6.reject(new Error(msg.error));
105+
} else {
106+
pending6.resolve(msg.result);
107+
}
108+
}
109+
break;
110+
}
111+
99112
case "reload_response": {
100113
const pending3 = pendingRequests.get(msg.id);
101114
if (pending3) {
@@ -342,6 +355,41 @@ export function createWSControlServer(port) {
342355
});
343356
}
344357

358+
function requestExecJsLivePreview(code, instanceName) {
359+
return new Promise((resolve, reject) => {
360+
const resolved = _resolveClient(instanceName);
361+
if (resolved.error) {
362+
reject(new Error(resolved.error));
363+
return;
364+
}
365+
366+
const { client } = resolved;
367+
if (client.ws.readyState !== 1) {
368+
reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected"));
369+
return;
370+
}
371+
372+
const id = ++requestIdCounter;
373+
const timeout = setTimeout(() => {
374+
pendingRequests.delete(id);
375+
reject(new Error("exec_js_live_preview request timed out (60s)"));
376+
}, 60000);
377+
378+
pendingRequests.set(id, {
379+
resolve: (data) => {
380+
clearTimeout(timeout);
381+
resolve(data);
382+
},
383+
reject: (err) => {
384+
clearTimeout(timeout);
385+
reject(err);
386+
}
387+
});
388+
389+
client.ws.send(JSON.stringify({ type: "exec_js_live_preview_request", id, code }));
390+
});
391+
}
392+
345393
function getBrowserLogs(sinceLast, instanceName) {
346394
const resolved = _resolveClient(instanceName);
347395
if (resolved.error) {
@@ -393,6 +441,7 @@ export function createWSControlServer(port) {
393441
requestReload,
394442
requestLogs,
395443
requestExecJs,
444+
requestExecJsLivePreview,
396445
getBrowserLogs,
397446
clearBrowserLogs,
398447
isClientConnected,

src/phoenix-builder/phoenix-builder-client.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ define(function (require, exports, module) {
2727

2828
const CommandManager = require("command/CommandManager");
2929
const Commands = require("command/Commands");
30+
const LiveDevProtocol = require("LiveDevelopment/MultiBrowserImpl/protocol/LiveDevProtocol");
31+
const LiveDevMain = require("LiveDevelopment/main");
32+
const WorkspaceManager = require("view/WorkspaceManager");
3033

3134
const boot = window._phoenixBuilder || null;
3235

@@ -87,10 +90,90 @@ define(function (require, exports, module) {
8790
});
8891
}
8992

93+
function _handleExecJsLivePreviewRequest(msg) {
94+
function _evaluate() {
95+
LiveDevProtocol.evaluate(msg.code)
96+
.done(function (evalResult) {
97+
boot.sendMessage({
98+
type: "exec_js_live_preview_response",
99+
id: msg.id,
100+
result: JSON.stringify(evalResult)
101+
});
102+
})
103+
.fail(function (err) {
104+
boot.sendMessage({
105+
type: "exec_js_live_preview_response",
106+
id: msg.id,
107+
error: (err && err.message) || String(err) || "evaluate() failed"
108+
});
109+
});
110+
}
111+
112+
// Fast path: already connected
113+
if (LiveDevProtocol.getConnectionIds().length > 0) {
114+
_evaluate();
115+
return;
116+
}
117+
118+
// Need to ensure live preview is open and connected
119+
const panel = WorkspaceManager.getPanelForID("live-preview-panel");
120+
if (!panel || !panel.isVisible()) {
121+
CommandManager.execute("file.liveFilePreview");
122+
} else {
123+
LiveDevMain.openLivePreview();
124+
}
125+
126+
// Wait for a live preview connection (up to 30s)
127+
const TIMEOUT = 30000;
128+
const POLL_INTERVAL = 500;
129+
let settled = false;
130+
let pollTimer = null;
131+
132+
function cleanup() {
133+
settled = true;
134+
if (pollTimer) {
135+
clearInterval(pollTimer);
136+
pollTimer = null;
137+
}
138+
LiveDevProtocol.off("ConnectionConnect.execJsLivePreview");
139+
}
140+
141+
const timeoutTimer = setTimeout(function () {
142+
if (settled) { return; }
143+
cleanup();
144+
boot.sendMessage({
145+
type: "exec_js_live_preview_response",
146+
id: msg.id,
147+
error: "Timed out waiting for live preview connection (30s)"
148+
});
149+
}, TIMEOUT);
150+
151+
function onConnected() {
152+
if (settled) { return; }
153+
cleanup();
154+
clearTimeout(timeoutTimer);
155+
_evaluate();
156+
}
157+
158+
LiveDevProtocol.on("ConnectionConnect.execJsLivePreview", onConnected);
159+
160+
// Safety-net poll in case the event was missed
161+
pollTimer = setInterval(function () {
162+
if (settled) {
163+
clearInterval(pollTimer);
164+
return;
165+
}
166+
if (LiveDevProtocol.getConnectionIds().length > 0) {
167+
onConnected();
168+
}
169+
}, POLL_INTERVAL);
170+
}
171+
90172
// Register handlers on the boot module
91173
if (boot) {
92174
boot.registerHandler("screenshot_request", _handleScreenshotRequest);
93175
boot.registerHandler("reload_request", _handleReloadRequest);
176+
boot.registerHandler("exec_js_live_preview_request", _handleExecJsLivePreviewRequest);
94177
}
95178

96179
exports.connect = function (url) { if (boot) { boot.connect(url); } };

0 commit comments

Comments
 (0)