Skip to content

Commit 0a564bd

Browse files
committed
fix(mcp): prevent test iframe from connecting to MCP and add exec_js_in_test_iframe tool
The embedded Phoenix iframe inside SpecRunner was connecting to MCP independently, causing instance confusion. Now only the SpecRunner connects to MCP, and a new exec_js_in_test_iframe tool bridges JS execution to the iframe when needed.
1 parent b091d6c commit 0a564bd

4 files changed

Lines changed: 129 additions & 0 deletions

File tree

phoenix-builder-mcp/mcp-tools.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,39 @@ export function registerTools(server, processManager, wsControlServer, phoenixDe
383383
}
384384
);
385385

386+
server.tool(
387+
"exec_js_in_test_iframe",
388+
"Execute JavaScript in the embedded test Phoenix iframe inside the SpecRunner, NOT in the SpecRunner itself. " +
389+
"The iframe is usually not present during unit tests, but for other categories tests may spawn it as needed — " +
390+
"it can come and go at any time. " +
391+
"Code runs async in the iframe's page context with access to the test Phoenix instance's globals " +
392+
"(jQuery $, brackets.test.*, etc.). " +
393+
"Returns an error if no iframe is present. " +
394+
"Use exec_js to control the SpecRunner (run tests, get results); use this tool to inspect the test Phoenix instance.",
395+
{
396+
code: z.string().describe("JavaScript code to execute in the test Phoenix iframe"),
397+
instance: z.string().optional().describe("Target a specific test runner instance by name. Required when multiple instances are connected.")
398+
},
399+
async ({ code, instance }) => {
400+
try {
401+
const result = await wsControlServer.requestExecJsInTestIframe(code, instance);
402+
return {
403+
content: [{
404+
type: "text",
405+
text: result !== undefined ? String(result) : "(undefined)"
406+
}]
407+
};
408+
} catch (err) {
409+
return {
410+
content: [{
411+
type: "text",
412+
text: JSON.stringify({ error: err.message })
413+
}]
414+
};
415+
}
416+
}
417+
);
418+
386419
server.tool(
387420
"run_tests",
388421
"Run tests in the Phoenix test runner (SpecRunner.html). Reloads the test runner with the specified " +

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@ export function createWSControlServer(port) {
109109
break;
110110
}
111111

112+
case "exec_js_in_test_iframe_response": {
113+
const pending7 = pendingRequests.get(msg.id);
114+
if (pending7) {
115+
pendingRequests.delete(msg.id);
116+
if (msg.error) {
117+
pending7.reject(new Error(msg.error));
118+
} else {
119+
pending7.resolve(msg.result);
120+
}
121+
}
122+
break;
123+
}
124+
112125
case "run_tests_response": {
113126
const pendingRt = pendingRequests.get(msg.id);
114127
if (pendingRt) {
@@ -412,6 +425,41 @@ export function createWSControlServer(port) {
412425
});
413426
}
414427

428+
function requestExecJsInTestIframe(code, instanceName) {
429+
return new Promise((resolve, reject) => {
430+
const resolved = _resolveClient(instanceName);
431+
if (resolved.error) {
432+
reject(new Error(resolved.error));
433+
return;
434+
}
435+
436+
const { client } = resolved;
437+
if (client.ws.readyState !== 1) {
438+
reject(new Error("Phoenix client \"" + resolved.name + "\" is not connected"));
439+
return;
440+
}
441+
442+
const id = ++requestIdCounter;
443+
const timeout = setTimeout(() => {
444+
pendingRequests.delete(id);
445+
reject(new Error("exec_js_in_test_iframe request timed out (30s)"));
446+
}, 30000);
447+
448+
pendingRequests.set(id, {
449+
resolve: (data) => {
450+
clearTimeout(timeout);
451+
resolve(data);
452+
},
453+
reject: (err) => {
454+
clearTimeout(timeout);
455+
reject(err);
456+
}
457+
});
458+
459+
client.ws.send(JSON.stringify({ type: "exec_js_in_test_iframe_request", id, code }));
460+
});
461+
}
462+
415463
function requestRunTests(category, spec, instanceName) {
416464
return new Promise((resolve, reject) => {
417465
const resolved = _resolveClient(instanceName);
@@ -538,6 +586,7 @@ export function createWSControlServer(port) {
538586
requestLogs,
539587
requestExecJs,
540588
requestExecJsLivePreview,
589+
requestExecJsInTestIframe,
541590
requestRunTests,
542591
requestTestResults,
543592
getBrowserLogs,

src/phoenix-builder/phoenix-builder-boot.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
if (!window.AppConfig || AppConfig.config.environment !== "dev") {
3434
return;
3535
}
36+
// Skip MCP in test windows (the embedded Phoenix iframe inside SpecRunner).
37+
// Only the SpecRunner itself and the normal Phoenix app should connect to MCP.
38+
// Phoenix.isTestWindow is true for both SpecRunner and the test iframe,
39+
// but Phoenix.isSpecRunnerWindow is only true for the SpecRunner itself.
40+
if (window.Phoenix && window.Phoenix.isTestWindow && !window.Phoenix.isSpecRunnerWindow) {
41+
return;
42+
}
3643

3744
// --- Constants ---
3845
const LOG_TO_CONSOLE_KEY = "logToConsole";

test/phoenix-test-runner-mcp.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,44 @@
185185
};
186186
}
187187

188+
// --- exec_js_in_test_iframe_request ---
189+
// When the SpecRunner has an embedded test iframe, forward exec_js to it
190+
// so MCP tools can operate on the test Phoenix instance inside the iframe.
191+
// Falls back to the SpecRunner context if no iframe is present (e.g. unit tests).
192+
builder.registerHandler("exec_js_in_test_iframe_request", function (msg) {
193+
var iframe = document.querySelector(".phoenixIframe");
194+
if (!iframe || !iframe.contentWindow) {
195+
builder.sendMessage({
196+
type: "exec_js_in_test_iframe_response",
197+
id: msg.id,
198+
error: "No test iframe present. The embedded Phoenix instance is not currently loaded."
199+
});
200+
return;
201+
}
202+
var targetWindow = iframe.contentWindow;
203+
// Create the async function in the target window's context so globals
204+
// like $, brackets, etc. resolve to the iframe's scope.
205+
var AsyncFunction = targetWindow.eval("(async function(){}).constructor");
206+
var fn = new AsyncFunction(msg.code);
207+
fn().then(function (result) {
208+
var text;
209+
try {
210+
text = (result !== undefined && result !== null) ? JSON.stringify(result) : "(undefined)";
211+
} catch (e) {
212+
text = String(result);
213+
}
214+
builder.sendMessage({
215+
type: "exec_js_in_test_iframe_response",
216+
id: msg.id,
217+
result: text
218+
});
219+
}).catch(function (err) {
220+
builder.sendMessage({
221+
type: "exec_js_in_test_iframe_response",
222+
id: msg.id,
223+
error: (err && err.stack) || (err && err.message) || String(err)
224+
});
225+
});
226+
});
227+
188228
}());

0 commit comments

Comments
 (0)