Skip to content

Commit a84d88b

Browse files
committed
fix(ai): safety-net timeouts and tool-result logging for MCP editor tools
Adds per-tool timeout budgets around execPeer in the MCP editor tools so a stalled browser round-trip surfaces a deterministic error to Claude instead of hanging until the CLI kills the call. execJsInLivePreview is excluded because user code has no natural upper bound. Also logs each tool_result content block (isError, length, preview) so a tool that silently returns an error payload can be correlated with its earlier "Tool done" input log.
1 parent 3ddc8cf commit a84d88b

2 files changed

Lines changed: 68 additions & 8 deletions

File tree

src-node/claude-code-agent.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,36 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale,
11511151
}
11521152
}
11531153
}
1154+
1155+
// Tool results come back as user-typed messages with content blocks
1156+
// of type tool_result. Log isError + content size so we can correlate
1157+
// a "Tool done" (input stream) with what Claude actually saw as the reply.
1158+
if (message.type === "user" && message.message && Array.isArray(message.message.content)) {
1159+
for (const block of message.message.content) {
1160+
if (block && block.type === "tool_result") {
1161+
let len = 0;
1162+
let preview = "";
1163+
if (typeof block.content === "string") {
1164+
len = block.content.length;
1165+
preview = block.content.slice(0, 120);
1166+
} else if (Array.isArray(block.content)) {
1167+
for (const c of block.content) {
1168+
if (c && c.type === "text" && typeof c.text === "string") {
1169+
len += c.text.length;
1170+
if (!preview) { preview = c.text.slice(0, 120); }
1171+
} else if (c && c.type === "image" && typeof c.data === "string") {
1172+
len += c.data.length;
1173+
if (!preview) { preview = "[image " + c.data.length + "ch]"; }
1174+
}
1175+
}
1176+
}
1177+
_log("Tool result:", block.tool_use_id || "?",
1178+
"isError=" + !!block.is_error,
1179+
"len=" + len + "ch",
1180+
preview ? ("preview=" + JSON.stringify(preview)) : "");
1181+
}
1182+
}
1183+
}
11541184
}
11551185

11561186
// Flush any remaining accumulated text

src-node/mcp-editor-tools.js

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,36 @@ const CLARIFICATION_HINT =
3535
"IMPORTANT: The user has typed a follow-up clarification while you were working." +
3636
" Call the getUserClarification tool to read it before proceeding.";
3737

38+
// Per-tool safety-net budgets for the browser round-trip. The node connector
39+
// is reliable in practice, so these should never fire during normal use —
40+
// they exist so a stalled promise chain (live preview wedged, etc.) surfaces
41+
// a deterministic error to Claude instead of the handler hanging forever.
42+
// Tools whose runtime is bounded by user-supplied code (execJsInLivePreview)
43+
// intentionally have no timeout — the code is allowed to run as long as it takes.
44+
const EXEC_PEER_TIMEOUT_MS = {
45+
getEditorState: 5000,
46+
takeScreenshot: 15000,
47+
controlEditor: 5000,
48+
resizeLivePreview: 5000
49+
};
50+
51+
function _execPeerWithTimeout(nodeConnector, fn, args, label) {
52+
const ms = EXEC_PEER_TIMEOUT_MS[fn];
53+
const call = nodeConnector.execPeer(fn, args);
54+
if (!ms) {
55+
return call; // no timeout configured for this tool
56+
}
57+
let timer;
58+
const timeout = new Promise(function (_resolve, reject) {
59+
timer = setTimeout(function () {
60+
reject(new Error(label + " timed out after " + ms + "ms"));
61+
}, ms);
62+
});
63+
return Promise.race([call, timeout]).finally(function () {
64+
clearTimeout(timer);
65+
});
66+
}
67+
3868
/**
3969
* Append a clarification hint to an MCP tool result if the user has queued a message.
4070
*/
@@ -70,7 +100,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
70100
async function () {
71101
let result;
72102
try {
73-
const state = await nodeConnector.execPeer("getEditorState", {});
103+
const state = await _execPeerWithTimeout(nodeConnector, "getEditorState", {}, "getEditorState");
74104
result = {
75105
content: [{ type: "text", text: JSON.stringify(state) }]
76106
};
@@ -107,11 +137,11 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
107137
async function (args) {
108138
let toolResult;
109139
try {
110-
const result = await nodeConnector.execPeer("takeScreenshot", {
140+
const result = await _execPeerWithTimeout(nodeConnector, "takeScreenshot", {
111141
selector: args.selector || undefined,
112142
purePreview: args.purePreview || false,
113143
filePath: args.filePath || undefined
114-
});
144+
}, "takeScreenshot");
115145
if (result.filePath) {
116146
toolResult = {
117147
content: [{ type: "text", text: "Screenshot saved to: " + result.filePath }]
@@ -148,9 +178,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
148178
async function (args) {
149179
let toolResult;
150180
try {
151-
const result = await nodeConnector.execPeer("execJsInLivePreview", {
181+
const result = await _execPeerWithTimeout(nodeConnector, "execJsInLivePreview", {
152182
code: args.code
153-
});
183+
}, "execJsInLivePreview");
154184
if (result.error) {
155185
toolResult = {
156186
content: [{ type: "text", text: "Error: " + result.error }],
@@ -202,7 +232,7 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
202232
for (const op of args.operations) {
203233
console.log("[Phoenix AI] controlEditor:", op.operation, op.filePath);
204234
try {
205-
const result = await nodeConnector.execPeer("controlEditor", op);
235+
const result = await _execPeerWithTimeout(nodeConnector, "controlEditor", op, "controlEditor:" + op.operation);
206236
results.push(result);
207237
if (!result.success) {
208238
hasError = true;
@@ -234,9 +264,9 @@ function createEditorMcpServer(sdkModule, nodeConnector, clarificationAccessors)
234264
async function (args) {
235265
let toolResult;
236266
try {
237-
const result = await nodeConnector.execPeer("resizeLivePreview", {
267+
const result = await _execPeerWithTimeout(nodeConnector, "resizeLivePreview", {
238268
width: args.width
239-
});
269+
}, "resizeLivePreview");
240270
if (result.error) {
241271
toolResult = {
242272
content: [{ type: "text", text: "Error: " + result.error }],

0 commit comments

Comments
 (0)