Skip to content

Commit 5e18379

Browse files
committed
feat: add in-process MCP server for editor context tools
Add getEditorState and takeScreenshot as MCP tools available to the Claude Code SDK during AI chat queries. This gives Claude awareness of which file the user is editing, their working set, live preview file, and the ability to capture screenshots of the editor. - Create src-node/mcp-editor-tools.js with tool definitions using the SDK's createSdkMcpServer/tool helpers - Wire MCP server into claude-code-agent.js queryOptions - Extract NodeConnector handlers into src/core-ai/aiPhoenixConnectors.js (getEditorState, takeScreenshot, getFileContent, applyEditToBuffer) - Add Document.posFromIndex() to support position conversion without a master editor attached - Add unit tests for Document.posFromIndex
1 parent 97fe90a commit 5e18379

File tree

9 files changed

+497
-162
lines changed

9 files changed

+497
-162
lines changed

src-node/claude-code-agent.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
const { execSync } = require("child_process");
3030
const path = require("path");
31+
const { createEditorMcpServer } = require("./mcp-editor-tools");
3132

3233
const CONNECTOR_ID = "ph_ai_claude";
3334

@@ -40,6 +41,9 @@ let currentSessionId = null;
4041
// Active query state
4142
let currentAbortController = null;
4243

44+
// Lazily-initialized in-process MCP server for editor context
45+
let editorMcpServer = null;
46+
4347
// Streaming throttle
4448
const TEXT_STREAM_THROTTLE_MS = 50;
4549

@@ -183,6 +187,9 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
183187

184188
try {
185189
queryFn = await getQueryFn();
190+
if (!editorMcpServer) {
191+
editorMcpServer = createEditorMcpServer(queryModule, nodeConnector);
192+
}
186193
} catch (err) {
187194
nodeConnector.triggerPeer("aiError", {
188195
requestId: requestId,
@@ -201,7 +208,12 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
201208
const queryOptions = {
202209
cwd: projectPath || process.cwd(),
203210
maxTurns: 10,
204-
allowedTools: ["Read", "Edit", "Write", "Glob", "Grep"],
211+
allowedTools: [
212+
"Read", "Edit", "Write", "Glob", "Grep",
213+
"mcp__phoenix-editor__getEditorState",
214+
"mcp__phoenix-editor__takeScreenshot"
215+
],
216+
mcpServers: { "phoenix-editor": editorMcpServer },
205217
permissionMode: "acceptEdits",
206218
appendSystemPrompt:
207219
"When modifying an existing file, always prefer the Edit tool " +

src-node/mcp-editor-tools.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it
7+
* under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
* for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
18+
*
19+
*/
20+
21+
/**
22+
* MCP server factory for exposing Phoenix editor context to Claude Code.
23+
*
24+
* Provides two tools:
25+
* - getEditorState: returns active file, working set, and live preview file
26+
* - takeScreenshot: captures a screenshot of the Phoenix window as base64 PNG
27+
*
28+
* Uses the Claude Code SDK's in-process MCP server support (createSdkMcpServer / tool).
29+
*/
30+
31+
const { z } = require("zod");
32+
33+
/**
34+
* Create an in-process MCP server exposing editor context tools.
35+
*
36+
* @param {Object} sdkModule - The imported @anthropic-ai/claude-code ESM module
37+
* @param {Object} nodeConnector - The NodeConnector instance for communicating with the browser
38+
* @returns {McpSdkServerConfigWithInstance} MCP server config ready for queryOptions.mcpServers
39+
*/
40+
function createEditorMcpServer(sdkModule, nodeConnector) {
41+
const getEditorStateTool = sdkModule.tool(
42+
"getEditorState",
43+
"Get the current Phoenix editor state: active file, working set (open files), and live preview file.",
44+
{},
45+
async function () {
46+
try {
47+
const state = await nodeConnector.execPeer("getEditorState", {});
48+
return {
49+
content: [{ type: "text", text: JSON.stringify(state) }]
50+
};
51+
} catch (err) {
52+
return {
53+
content: [{ type: "text", text: "Error getting editor state: " + err.message }],
54+
isError: true
55+
};
56+
}
57+
}
58+
);
59+
60+
const takeScreenshotTool = sdkModule.tool(
61+
"takeScreenshot",
62+
"Take a screenshot of the Phoenix Code editor window. Returns a PNG image.",
63+
{ selector: z.string().optional().describe("Optional CSS selector to capture a specific element") },
64+
async function (args) {
65+
try {
66+
const result = await nodeConnector.execPeer("takeScreenshot", {
67+
selector: args.selector || undefined
68+
});
69+
if (result.base64) {
70+
return {
71+
content: [{ type: "image", data: result.base64, mimeType: "image/png" }]
72+
};
73+
}
74+
return {
75+
content: [{ type: "text", text: result.error || "Screenshot failed" }],
76+
isError: true
77+
};
78+
} catch (err) {
79+
return {
80+
content: [{ type: "text", text: "Error taking screenshot: " + err.message }],
81+
isError: true
82+
};
83+
}
84+
}
85+
);
86+
87+
return sdkModule.createSdkMcpServer({
88+
name: "phoenix-editor",
89+
tools: [getEditorStateTool, takeScreenshotTool]
90+
});
91+
}
92+
93+
exports.createEditorMcpServer = createEditorMcpServer;

src-node/package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-node/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,16 @@
1919
},
2020
"IMPORTANT!!": "Adding things here will bloat up the package size",
2121
"dependencies": {
22+
"@anthropic-ai/claude-code": "^1.0.0",
23+
"@expo/sudo-prompt": "^9.3.2",
2224
"@phcode/fs": "^4.0.2",
23-
"open": "^10.1.0",
24-
"npm": "11.8.0",
25-
"ws": "^8.17.1",
25+
"cross-spawn": "^7.0.6",
2626
"lmdb": "^3.5.1",
2727
"mime-types": "^2.1.35",
28-
"cross-spawn": "^7.0.6",
28+
"npm": "11.8.0",
29+
"open": "^10.1.0",
2930
"which": "^2.0.1",
30-
"@expo/sudo-prompt": "^9.3.2",
31-
"@anthropic-ai/claude-code": "^1.0.0"
31+
"ws": "^8.17.1",
32+
"zod": "^3.25.76"
3233
}
33-
}
34+
}

src/core-ai/AIChatPanel.js

Lines changed: 13 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,24 @@
2424
*/
2525
define(function (require, exports, module) {
2626

27-
const SidebarTabs = require("view/SidebarTabs"),
28-
DocumentManager = require("document/DocumentManager"),
29-
CommandManager = require("command/CommandManager"),
30-
Commands = require("command/Commands"),
31-
ProjectManager = require("project/ProjectManager"),
32-
FileSystem = require("filesystem/FileSystem"),
33-
SnapshotStore = require("core-ai/AISnapshotStore"),
34-
Strings = require("strings"),
35-
StringUtils = require("utils/StringUtils"),
36-
marked = require("thirdparty/marked.min");
27+
const SidebarTabs = require("view/SidebarTabs"),
28+
DocumentManager = require("document/DocumentManager"),
29+
CommandManager = require("command/CommandManager"),
30+
Commands = require("command/Commands"),
31+
ProjectManager = require("project/ProjectManager"),
32+
FileSystem = require("filesystem/FileSystem"),
33+
SnapshotStore = require("core-ai/AISnapshotStore"),
34+
PhoenixConnectors = require("core-ai/aiPhoenixConnectors"),
35+
Strings = require("strings"),
36+
StringUtils = require("utils/StringUtils"),
37+
marked = require("thirdparty/marked.min");
3738

3839
let _nodeConnector = null;
3940
let _isStreaming = false;
4041
let _currentRequestId = null;
4142
let _segmentText = ""; // text for the current segment only
4243
let _autoScroll = true;
4344
let _hasReceivedContent = false; // tracks if we've received any text/tool in current response
44-
const _previousContentMap = {}; // filePath → previous content before edit, for undo support
4545
let _currentEdits = []; // edits in current response, for summary card
4646
let _firstEditInResponse = true; // tracks first edit per response for initial PUC
4747
let _undoApplied = false; // whether undo/restore has been clicked on any card
@@ -299,9 +299,7 @@ define(function (require, exports, module) {
299299
_firstEditInResponse = true;
300300
_undoApplied = false;
301301
SnapshotStore.reset();
302-
Object.keys(_previousContentMap).forEach(function (key) {
303-
delete _previousContentMap[key];
304-
});
302+
PhoenixConnectors.clearPreviousContentMap();
305303
if ($messages) {
306304
$messages.empty();
307305
}
@@ -543,7 +541,7 @@ define(function (require, exports, module) {
543541
});
544542

545543
// Capture pre-edit content for snapshot tracking
546-
const previousContent = _previousContentMap[edit.file];
544+
const previousContent = PhoenixConnectors.getPreviousContent(edit.file);
547545
const isNewFile = (edit.oldText === null && (previousContent === undefined || previousContent === ""));
548546

549547
// On first edit per response, insert initial PUC if needed.
@@ -1102,100 +1100,6 @@ define(function (require, exports, module) {
11021100
return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
11031101
}
11041102

1105-
// --- Edit application ---
1106-
1107-
/**
1108-
* Apply a single edit to a document buffer and save to disk.
1109-
* Called immediately when Claude's Write/Edit is intercepted, so
1110-
* subsequent Reads see the new content both in the buffer and on disk.
1111-
* @param {Object} edit - {file, oldText, newText}
1112-
* @return {$.Promise} resolves with {previousContent} for undo support
1113-
*/
1114-
function _applySingleEdit(edit) {
1115-
const result = new $.Deferred();
1116-
const vfsPath = SnapshotStore.realToVfsPath(edit.file);
1117-
1118-
function _applyToDoc() {
1119-
DocumentManager.getDocumentForPath(vfsPath)
1120-
.done(function (doc) {
1121-
try {
1122-
const previousContent = doc.getText();
1123-
if (edit.oldText === null) {
1124-
// Write (new file or full replacement)
1125-
doc.setText(edit.newText);
1126-
} else {
1127-
// Edit — find oldText and replace
1128-
const docText = doc.getText();
1129-
const idx = docText.indexOf(edit.oldText);
1130-
if (idx === -1) {
1131-
result.reject(new Error(Strings.AI_CHAT_EDIT_NOT_FOUND));
1132-
return;
1133-
}
1134-
const startPos = doc._masterEditor ?
1135-
doc._masterEditor._codeMirror.posFromIndex(idx) :
1136-
_indexToPos(docText, idx);
1137-
const endPos = doc._masterEditor ?
1138-
doc._masterEditor._codeMirror.posFromIndex(idx + edit.oldText.length) :
1139-
_indexToPos(docText, idx + edit.oldText.length);
1140-
doc.replaceRange(edit.newText, startPos, endPos);
1141-
}
1142-
// Open the file in the editor and save to disk
1143-
CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath });
1144-
SnapshotStore.saveDocToDisk(doc).always(function () {
1145-
result.resolve({ previousContent: previousContent });
1146-
});
1147-
} catch (err) {
1148-
result.reject(err);
1149-
}
1150-
})
1151-
.fail(function (err) {
1152-
result.reject(err || new Error("Could not open document"));
1153-
});
1154-
}
1155-
1156-
if (edit.oldText === null) {
1157-
// Write — file may not exist yet. Only create on disk if it doesn't
1158-
// already exist, to avoid triggering "external change" warnings.
1159-
const file = FileSystem.getFileForPath(vfsPath);
1160-
file.exists(function (existErr, exists) {
1161-
if (exists) {
1162-
// File exists — just open and set content, no disk write
1163-
_applyToDoc();
1164-
} else {
1165-
// New file — create on disk first so getDocumentForPath works
1166-
file.write("", function (writeErr) {
1167-
if (writeErr) {
1168-
result.reject(new Error("Could not create file: " + writeErr));
1169-
return;
1170-
}
1171-
_applyToDoc();
1172-
});
1173-
}
1174-
});
1175-
} else {
1176-
// Edit — file must already exist
1177-
_applyToDoc();
1178-
}
1179-
1180-
return result.promise();
1181-
}
1182-
1183-
/**
1184-
* Convert a character index in text to a {line, ch} position.
1185-
*/
1186-
function _indexToPos(text, index) {
1187-
let line = 0, ch = 0;
1188-
for (let i = 0; i < index; i++) {
1189-
if (text[i] === "\n") {
1190-
line++;
1191-
ch = 0;
1192-
} else {
1193-
ch++;
1194-
}
1195-
}
1196-
return { line: line, ch: ch };
1197-
}
1198-
11991103
// --- Path utilities ---
12001104

12011105
/**
@@ -1214,43 +1118,7 @@ define(function (require, exports, module) {
12141118
return fullPath;
12151119
}
12161120

1217-
/**
1218-
* Check if a file has unsaved changes in the editor and return its content.
1219-
* Used by the node-side Read hook to serve dirty buffer content to Claude.
1220-
*/
1221-
function getFileContent(params) {
1222-
const vfsPath = SnapshotStore.realToVfsPath(params.filePath);
1223-
const doc = DocumentManager.getOpenDocumentForPath(vfsPath);
1224-
if (doc && doc.isDirty) {
1225-
return { isDirty: true, content: doc.getText() };
1226-
}
1227-
return { isDirty: false, content: null };
1228-
}
1229-
1230-
/**
1231-
* Apply an edit to the editor buffer immediately (called by node-side hooks).
1232-
* The file appears as a dirty tab so subsequent Reads see the new content.
1233-
* @param {Object} params - {file, oldText, newText}
1234-
* @return {Promise<{applied: boolean, error?: string}>}
1235-
*/
1236-
function applyEditToBuffer(params) {
1237-
const deferred = new $.Deferred();
1238-
_applySingleEdit(params)
1239-
.done(function (result) {
1240-
if (result && result.previousContent !== undefined) {
1241-
_previousContentMap[params.file] = result.previousContent;
1242-
}
1243-
deferred.resolve({ applied: true });
1244-
})
1245-
.fail(function (err) {
1246-
deferred.resolve({ applied: false, error: err.message || String(err) });
1247-
});
1248-
return deferred.promise();
1249-
}
1250-
12511121
// Public API
12521122
exports.init = init;
12531123
exports.initPlaceholder = initPlaceholder;
1254-
exports.getFileContent = getFileContent;
1255-
exports.applyEditToBuffer = applyEditToBuffer;
12561124
});

0 commit comments

Comments
 (0)