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
19 changes: 19 additions & 0 deletions phoenix-builder-mcp/log-buffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export class LogBuffer {
constructor() {
this._entries = [];
this._readIndex = 0;
this._totalPushed = 0;
}

push(entry) {
this._entries.push(entry);
this._totalPushed++;
if (this._entries.length > MAX_ENTRIES) {
const overflow = this._entries.length - MAX_ENTRIES;
this._entries.splice(0, overflow);
Expand All @@ -25,6 +27,23 @@ export class LogBuffer {
return newEntries;
}

totalPushed() {
return this._totalPushed;
}

getTail(n, before) {
const firstIndex = this._totalPushed - this._entries.length;
let endIdx = this._entries.length;
if (before != null) {
endIdx = Math.max(0, Math.min(this._entries.length, before - firstIndex));
}
if (n === 0) {
return this._entries.slice(0, endIdx);
}
const startIdx = Math.max(0, endIdx - n);
return this._entries.slice(startIdx, endIdx);
}

clear() {
this._entries = [];
this._readIndex = 0;
Expand Down
167 changes: 157 additions & 10 deletions phoenix-builder-mcp/mcp-tools.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import { z } from "zod";

const DEFAULT_MAX_CHARS = 10000;

function _trimToCharBudget(lines, maxChars) {
let total = 0;
// Walk backwards (newest first) to keep the most recent entries
let startIdx = lines.length;
for (let i = lines.length - 1; i >= 0; i--) {
const cost = lines[i].length + 1; // +1 for newline
if (total + cost > maxChars) { break; }
total += cost;
startIdx = i;
}
return { lines: lines.slice(startIdx), trimmed: startIdx };
}

export function registerTools(server, processManager, wsControlServer, phoenixDesktopPath) {
server.tool(
"start_phoenix",
Expand Down Expand Up @@ -67,39 +82,138 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe

server.tool(
"get_terminal_logs",
"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.",
{ clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read.") },
async ({ clear }) => {
"Get stdout/stderr output from the Electron process. Returns last 50 entries by default. " +
"USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " +
"Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " +
"prefer filter + small tail to keep responses compact.",
{
clear: z.boolean().default(false).describe("If true, return all logs and clear the buffer. If false, return only new logs since last read."),
tail: z.number().default(50).describe("Return last N entries. 0 = all."),
before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."),
filter: z.string().optional().describe("Optional regex to filter log entries by text content. Applied before tail/before."),
maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.")
},
async ({ clear, tail, before, filter, maxChars }) => {
let logs;
if (clear) {
logs = processManager.getTerminalLogs(false);
processManager.clearTerminalLogs();
} else {
logs = processManager.getTerminalLogs(true);
}
const text = logs.map(e => `[${e.stream}] ${e.text}`).join("");
const totalEntries = processManager.getTerminalLogsTotalPushed();
let filterRe;
if (filter) {
try {
filterRe = new RegExp(filter, "i");
} catch (e) {
return {
content: [{
type: "text",
text: `Invalid filter regex: ${e.message}`
}]
};
}
logs = logs.filter(e => filterRe.test(e.text));
}
const matchedEntries = logs.length;
const endIdx = before != null ? Math.max(0, Math.min(matchedEntries, before)) : matchedEntries;
if (tail > 0) {
const startIdx = Math.max(0, endIdx - tail);
logs = logs.slice(startIdx, endIdx);
} else {
logs = logs.slice(0, endIdx);
}
let lines = logs.map(e => `[${e.stream}] ${e.text}`);
let trimmed = 0;
if (maxChars > 0) {
const result = _trimToCharBudget(lines, maxChars);
lines = result.lines;
trimmed = result.trimmed;
}
const showing = lines.length;
const rangeEnd = endIdx;
const rangeStart = rangeEnd - logs.length;
const actualStart = rangeStart + trimmed;
const hasMore = actualStart > 0;
let header = `[Logs: ${totalEntries} total`;
if (filter) {
header += `, ${matchedEntries} matched /${filter}/i`;
}
header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`;
if (trimmed > 0) {
header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`;
}
if (hasMore) {
header += ` hasMore=true, use before=${actualStart} to page back.`;
}
header += `]`;
const text = lines.join("");
return {
content: [{
type: "text",
text: text || "(no terminal logs)"
text: text ? header + "\n" + text : "(no terminal logs)"
}]
};
}
);

server.tool(
"get_browser_console_logs",
"Get console logs captured from the Phoenix browser runtime from boot time. Fetches the full retained log buffer directly from the browser instance.",
"Get console logs from the Phoenix browser runtime. Returns last 50 entries by default. " +
"USAGE: Start with default tail=50. Use filter (regex) to narrow results (e.g. filter='error|warn'). " +
"Use before=N (from previous totalEntries) to page back. Avoid tail=0 unless necessary — " +
"prefer filter + small tail to keep responses compact.",
{
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected."),
tail: z.number().default(50).describe("Return last N entries. 0 = all."),
before: z.number().optional().describe("Cursor: return entries before this totalEntries position. Use the totalEntries value from a previous response to page back stably."),
filter: z.string().optional().describe("Optional regex to filter log entries by message content. Applied before tail/before."),
maxChars: z.number().default(DEFAULT_MAX_CHARS).describe("Max character budget for log content. Oldest entries are dropped first to fit. 0 = unlimited.")
},
async ({ instance }) => {
async ({ instance, tail, before, filter, maxChars }) => {
try {
const logs = await wsControlServer.requestLogs(instance);
const result = await wsControlServer.requestLogs(instance, { tail, before, filter });
const entries = result.entries || [];
const totalEntries = result.totalEntries || entries.length;
const matchedEntries = result.matchedEntries != null ? result.matchedEntries : entries.length;
const rangeEnd = result.rangeEnd != null ? result.rangeEnd : matchedEntries;
let lines = entries.map(e => `[${e.level}] ${e.message}`);
let trimmed = 0;
if (maxChars > 0) {
const trimResult = _trimToCharBudget(lines, maxChars);
lines = trimResult.lines;
trimmed = trimResult.trimmed;
}
const showing = lines.length;
const rangeStart = rangeEnd - entries.length;
const actualStart = rangeStart + trimmed;
const hasMore = actualStart > 0;
let header = `[Logs: ${totalEntries} total`;
if (filter) {
header += `, ${matchedEntries} matched /${filter}/i`;
}
header += `, showing ${actualStart}-${rangeEnd} (${showing} entries).`;
if (trimmed > 0) {
header += ` ${trimmed} entries trimmed to fit maxChars=${maxChars}.`;
}
if (hasMore) {
header += ` hasMore=true, use before=${actualStart} to page back.`;
}
header += `]`;
if (showing === 0) {
return {
content: [{
type: "text",
text: "(no browser logs)"
}]
};
}
const text = lines.join("\n");
return {
content: [{
type: "text",
text: JSON.stringify(logs.length > 0 ? logs : "(no browser logs)")
text: header + "\n" + text
}]
};
} catch (err) {
Expand Down Expand Up @@ -226,6 +340,39 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe
}
);

server.tool(
"exec_js_in_live_preview",
"Execute JavaScript in the live preview iframe (the page being previewed), NOT in Phoenix itself. " +
"Auto-opens the live preview panel if it is not already visible. " +
"Code is evaluated via eval() in the global scope of the previewed page. " +
"Note: eval() is synchronous — async/await is NOT supported. " +
"Only available when an HTML file is selected in the live preview — " +
"does not work for markdown or other non-HTML file types. " +
"Use this to inspect or manipulate the user's live-previewed web page (e.g. document.title, DOM queries).",
{
code: z.string().describe("JavaScript code to execute in the live preview iframe"),
instance: z.string().optional().describe("Target a specific Phoenix instance by name (e.g. 'Phoenix-a3f2'). Required when multiple instances are connected.")
},
async ({ code, instance }) => {
try {
const result = await wsControlServer.requestExecJsLivePreview(code, instance);
return {
content: [{
type: "text",
text: result !== undefined ? String(result) : "(undefined)"
}]
};
} catch (err) {
return {
content: [{
type: "text",
text: JSON.stringify({ error: err.message })
}]
};
}
}
);

server.tool(
"get_phoenix_status",
"Check the status of the Phoenix process and WebSocket connection.",
Expand Down
7 changes: 6 additions & 1 deletion phoenix-builder-mcp/process-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,17 @@ export function createProcessManager() {
terminalLogs.clear();
}

function getTerminalLogsTotalPushed() {
return terminalLogs.totalPushed();
}

return {
start,
stop,
isRunning,
getPid,
getTerminalLogs,
clearTerminalLogs
clearTerminalLogs,
getTerminalLogsTotalPushed
};
}
67 changes: 64 additions & 3 deletions phoenix-builder-mcp/ws-control-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ export function createWSControlServer(port) {
const pending4 = pendingRequests.get(msg.id);
if (pending4) {
pendingRequests.delete(msg.id);
pending4.resolve(msg.entries || []);
pending4.resolve({
entries: msg.entries || [],
totalEntries: msg.totalEntries || (msg.entries ? msg.entries.length : 0),
matchedEntries: msg.matchedEntries,
rangeEnd: msg.rangeEnd
});
}
break;
}
Expand All @@ -91,6 +96,19 @@ export function createWSControlServer(port) {
break;
}

case "exec_js_live_preview_response": {
const pending6 = pendingRequests.get(msg.id);
if (pending6) {
pendingRequests.delete(msg.id);
if (msg.error) {
pending6.reject(new Error(msg.error));
} else {
pending6.resolve(msg.result);
}
}
break;
}

case "reload_response": {
const pending3 = pendingRequests.get(msg.id);
if (pending3) {
Expand Down Expand Up @@ -260,7 +278,7 @@ export function createWSControlServer(port) {
});
}

function requestLogs(instanceName) {
function requestLogs(instanceName, { tail = 50, before, filter } = {}) {
return new Promise((resolve, reject) => {
const resolved = _resolveClient(instanceName);
if (resolved.error) {
Expand Down Expand Up @@ -291,7 +309,14 @@ export function createWSControlServer(port) {
}
});

client.ws.send(JSON.stringify({ type: "get_logs_request", id }));
const msg = { type: "get_logs_request", id, tail };
if (before != null) {
msg.before = before;
}
if (filter) {
msg.filter = filter;
}
client.ws.send(JSON.stringify(msg));
});
}

Expand Down Expand Up @@ -330,6 +355,41 @@ export function createWSControlServer(port) {
});
}

function requestExecJsLivePreview(code, instanceName) {
return new Promise((resolve, reject) => {
const resolved = _resolveClient(instanceName);
if (resolved.error) {
reject(new Error(resolved.error));
return;
}

const { client } = resolved;
if (client.ws.readyState !== 1) {
reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected"));
return;
}

const id = ++requestIdCounter;
const timeout = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error("exec_js_live_preview request timed out (60s)"));
}, 60000);

pendingRequests.set(id, {
resolve: (data) => {
clearTimeout(timeout);
resolve(data);
},
reject: (err) => {
clearTimeout(timeout);
reject(err);
}
});

client.ws.send(JSON.stringify({ type: "exec_js_live_preview_request", id, code }));
});
}

function getBrowserLogs(sinceLast, instanceName) {
const resolved = _resolveClient(instanceName);
if (resolved.error) {
Expand Down Expand Up @@ -381,6 +441,7 @@ export function createWSControlServer(port) {
requestReload,
requestLogs,
requestExecJs,
requestExecJsLivePreview,
getBrowserLogs,
clearBrowserLogs,
isClientConnected,
Expand Down
Loading
Loading