Skip to content

Commit 54e7a1a

Browse files
committed
Implement per-session MCP server polling with headless PTY
- Add headless PTY poller for each session that runs in session's worktree directory - Spawn MCP poller when Claude shows > prompt (authenticated context) - Parse MCP server output with regex, merge servers by name to handle chunked data - Show/hide MCP section based on active session - Clean up MCP pollers on session close/delete - Add unique UUID suffix to branch names to prevent conflicts on recreate - Remove global MCP polling interval in favor of per-session polling
1 parent 8dfe6be commit 54e7a1a

File tree

3 files changed

+185
-27
lines changed

3 files changed

+185
-27
lines changed

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ <h1 class="sidebar-title">FleetCode</h1>
3131
<div id="session-list"></div>
3232
</div>
3333

34-
<div class="sidebar-section">
34+
<div class="sidebar-section" id="mcp-section" style="display: none;">
3535
<div class="sidebar-section-header">
3636
<div class="sidebar-section-title">MCP SERVERS</div>
3737
<button id="add-mcp-server" class="section-add-btn" title="Add MCP Server">+</button>

main.ts

Lines changed: 157 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface PersistedSession {
3131

3232
let mainWindow: BrowserWindow;
3333
const activePtyProcesses = new Map<string, pty.IPty>();
34+
const mcpPollerPtyProcesses = new Map<string, pty.IPty>();
3435
const store = new Store();
3536

3637
// Helper functions for session management
@@ -48,6 +49,108 @@ function getNextSessionNumber(): number {
4849
return Math.max(...sessions.map(s => s.number)) + 1;
4950
}
5051

52+
// Spawn headless PTY for MCP polling
53+
function spawnMcpPoller(sessionId: string, worktreePath: string) {
54+
const shell = os.platform() === "darwin" ? "zsh" : "bash";
55+
const ptyProcess = pty.spawn(shell, ["-l"], {
56+
name: "xterm-color",
57+
cols: 80,
58+
rows: 30,
59+
cwd: worktreePath,
60+
env: process.env,
61+
});
62+
63+
mcpPollerPtyProcesses.set(sessionId, ptyProcess);
64+
65+
let outputBuffer = "";
66+
const serverMap = new Map<string, any>();
67+
68+
ptyProcess.onData((data) => {
69+
// Accumulate output without displaying it
70+
outputBuffer += data;
71+
72+
// Parse output whenever we have MCP server entries
73+
// Match lines like: "servername: url (type) - ✓ Connected" or "servername: command (stdio) - ✓ Connected"
74+
// Pattern handles both SSE (with URLs) and stdio (with commands/paths)
75+
const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|stdio)\)\s+-\s+[]/m;
76+
77+
if (mcpServerLineRegex.test(data) || data.includes("No MCP servers configured")) {
78+
try {
79+
const servers = parseMcpOutput(outputBuffer);
80+
81+
// Merge servers into the map (upsert by name)
82+
servers.forEach(server => {
83+
serverMap.set(server.name, server);
84+
});
85+
86+
const allServers = Array.from(serverMap.values());
87+
88+
if (mainWindow && !mainWindow.isDestroyed()) {
89+
mainWindow.webContents.send("mcp-servers-updated", sessionId, allServers);
90+
}
91+
} catch (error) {
92+
console.error("Error parsing MCP output:", error);
93+
}
94+
}
95+
96+
// Clear buffer when we see the shell prompt (command finished)
97+
if ((data.includes("% ") || data.includes("$ ") || data.includes("➜ ")) &&
98+
outputBuffer.includes("claude mcp list")) {
99+
outputBuffer = "";
100+
}
101+
});
102+
103+
// Wait for shell to be ready, then start polling loop
104+
setTimeout(() => {
105+
// Run `claude mcp list` every 60 seconds
106+
const pollMcp = () => {
107+
if (mcpPollerPtyProcesses.has(sessionId)) {
108+
ptyProcess.write("claude mcp list\r");
109+
setTimeout(pollMcp, 60000);
110+
}
111+
};
112+
pollMcp();
113+
}, 2000); // Wait 2s for shell to initialize
114+
}
115+
116+
// Parse MCP server list output
117+
function parseMcpOutput(output: string): any[] {
118+
const servers = [];
119+
120+
if (output.includes("No MCP servers configured")) {
121+
return [];
122+
}
123+
124+
const lines = output.trim().split("\n").filter(line => line.trim());
125+
126+
for (const line of lines) {
127+
// Skip header lines, empty lines, and status messages
128+
if (line.includes("MCP servers") ||
129+
line.includes("---") ||
130+
line.includes("Checking") ||
131+
line.includes("health") ||
132+
line.includes("claude mcp list") ||
133+
!line.trim()) {
134+
continue;
135+
}
136+
137+
// Only parse lines that match the MCP server format
138+
// Must have: "name: something (SSE|stdio) - status"
139+
const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|stdio)\)\s+-\s+[]/);
140+
if (serverMatch) {
141+
const serverName = serverMatch[1];
142+
const isConnected = line.includes("✓") || line.includes("Connected");
143+
144+
servers.push({
145+
name: serverName,
146+
connected: isConnected
147+
});
148+
}
149+
}
150+
151+
return servers;
152+
}
153+
51154
// Helper function to spawn PTY and setup coding agent
52155
function spawnSessionPty(
53156
sessionId: string,
@@ -68,6 +171,7 @@ function spawnSessionPty(
68171
activePtyProcesses.set(sessionId, ptyProcess);
69172

70173
let terminalReady = false;
174+
let claudeReady = false;
71175
let dataBuffer = "";
72176

73177
ptyProcess.onData((data) => {
@@ -115,6 +219,18 @@ function spawnSessionPty(
115219
}
116220
}
117221
}
222+
223+
// Detect when Claude is ready (shows "> " prompt)
224+
if (terminalReady && !claudeReady && config.codingAgent === "claude") {
225+
if (data.includes("> ")) {
226+
claudeReady = true;
227+
// Spawn MCP poller now that Claude is authenticated and ready
228+
// Check if poller doesn't already exist to prevent duplicates
229+
if (!mcpPollerPtyProcesses.has(sessionId)) {
230+
spawnMcpPoller(sessionId, worktreePath);
231+
}
232+
}
233+
}
118234
});
119235

120236
return ptyProcess;
@@ -159,12 +275,14 @@ async function ensureFleetcodeExcluded(projectDir: string) {
159275
}
160276
}
161277

162-
async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number): Promise<string> {
278+
async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number, sessionUuid: string): Promise<string> {
163279
const git = simpleGit(projectDir);
164280
const fleetcodeDir = path.join(projectDir, ".fleetcode");
165281
const worktreeName = `session${sessionNumber}`;
166282
const worktreePath = path.join(fleetcodeDir, worktreeName);
167-
const branchName = `fleetcode/session${sessionNumber}`;
283+
// Include short UUID to ensure branch uniqueness across deletes/recreates
284+
const shortUuid = sessionUuid.split('-')[0];
285+
const branchName = `fleetcode/session${sessionNumber}-${shortUuid}`;
168286

169287
// Create .fleetcode directory if it doesn't exist
170288
if (!fs.existsSync(fleetcodeDir)) {
@@ -250,14 +368,14 @@ ipcMain.on("create-session", async (event, config: SessionConfig) => {
250368
const sessionId = `session-${Date.now()}`;
251369
const sessionName = `Session ${sessionNumber}`;
252370

371+
// Generate UUID for this session (before creating worktree)
372+
const sessionUuid = uuidv4();
373+
253374
// Ensure .fleetcode is excluded (async, don't wait)
254375
ensureFleetcodeExcluded(config.projectDir);
255376

256-
// Create git worktree
257-
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber);
258-
259-
// Generate UUID for this session
260-
const sessionUuid = uuidv4();
377+
// Create git worktree with unique branch name
378+
const worktreePath = await createWorktree(config.projectDir, config.parentBranch, sessionNumber, sessionUuid);
261379

262380
// Create persisted session metadata
263381
const persistedSession: PersistedSession = {
@@ -332,6 +450,13 @@ ipcMain.on("close-session", (_event, sessionId: string) => {
332450
ptyProcess.kill();
333451
activePtyProcesses.delete(sessionId);
334452
}
453+
454+
// Kill MCP poller if active
455+
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
456+
if (mcpPoller) {
457+
mcpPoller.kill();
458+
mcpPollerPtyProcesses.delete(sessionId);
459+
}
335460
});
336461

337462
// Delete session (kill PTY, remove worktree, delete from store)
@@ -343,6 +468,13 @@ ipcMain.on("delete-session", async (_event, sessionId: string) => {
343468
activePtyProcesses.delete(sessionId);
344469
}
345470

471+
// Kill MCP poller if active
472+
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
473+
if (mcpPoller) {
474+
mcpPoller.kill();
475+
mcpPollerPtyProcesses.delete(sessionId);
476+
}
477+
346478
// Find and remove from persisted sessions
347479
const sessions = getPersistedSessions();
348480
const sessionIndex = sessions.findIndex(s => s.id === sessionId);
@@ -476,9 +608,15 @@ async function getMcpServerDetails(name: string) {
476608
}
477609
}
478610

479-
ipcMain.handle("list-mcp-servers", async () => {
611+
ipcMain.handle("list-mcp-servers", async (_event, sessionId: string) => {
480612
try {
481-
return await listMcpServers();
613+
// Trigger an immediate MCP list command in the session's poller
614+
const mcpPoller = mcpPollerPtyProcesses.get(sessionId);
615+
if (mcpPoller) {
616+
mcpPoller.write("claude mcp list\r");
617+
}
618+
// Return empty array - actual results will come via mcp-servers-updated event
619+
return [];
482620
} catch (error) {
483621
console.error("Error listing MCP servers:", error);
484622
return [];
@@ -541,20 +679,22 @@ const createWindow = () => {
541679
}
542680
});
543681
activePtyProcesses.clear();
682+
683+
// Kill all MCP poller processes
684+
mcpPollerPtyProcesses.forEach((ptyProcess, sessionId) => {
685+
try {
686+
ptyProcess.kill();
687+
} catch (error) {
688+
console.error(`Error killing MCP poller for session ${sessionId}:`, error);
689+
}
690+
});
691+
mcpPollerPtyProcesses.clear();
544692
});
545693
};
546694

547695
app.whenReady().then(() => {
548696
createWindow();
549697

550-
// Refresh MCP server list every minute and broadcast to all windows
551-
setInterval(async () => {
552-
const servers = await listMcpServers();
553-
BrowserWindow.getAllWindows().forEach(window => {
554-
window.webContents.send("mcp-servers-updated", servers);
555-
});
556-
}, 60000); // 60000ms = 1 minute
557-
558698
app.on("activate", () => {
559699
if (BrowserWindow.getAllWindows().length === 0) {
560700
createWindow();

renderer.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,15 @@ function switchToSession(sessionId: string) {
576576
document.getElementById(`sidebar-${sessionId}`)?.classList.add("active");
577577
activeSessionId = sessionId;
578578

579+
// Show MCP section when a session is active
580+
const mcpSection = document.getElementById("mcp-section");
581+
if (mcpSection) {
582+
mcpSection.style.display = "block";
583+
}
584+
585+
// Load MCP servers for this session
586+
loadMcpServers();
587+
579588
// Clear unread status when switching to this session
580589
clearUnreadStatus(sessionId);
581590

@@ -648,6 +657,11 @@ function closeSession(sessionId: string) {
648657
switchToSession(activeSessions[0].id);
649658
} else {
650659
activeSessionId = null;
660+
// Hide MCP section when no sessions are active
661+
const mcpSection = document.getElementById("mcp-section");
662+
if (mcpSection) {
663+
mcpSection.style.display = "none";
664+
}
651665
}
652666
}
653667
}
@@ -943,6 +957,11 @@ createBtn?.addEventListener("click", () => {
943957

944958
// MCP Server management functions
945959
async function loadMcpServers() {
960+
// Only load MCP servers if there's an active session
961+
if (!activeSessionId) {
962+
return;
963+
}
964+
946965
const addMcpServerBtn = document.getElementById("add-mcp-server");
947966

948967
// Show loading spinner
@@ -952,9 +971,8 @@ async function loadMcpServers() {
952971
}
953972

954973
try {
955-
const servers = await ipcRenderer.invoke("list-mcp-servers");
956-
mcpServers = servers;
957-
renderMcpServers();
974+
await ipcRenderer.invoke("list-mcp-servers", activeSessionId);
975+
// Results will come via mcp-servers-updated event
958976
} catch (error) {
959977
console.error("Failed to load MCP servers:", error);
960978
} finally {
@@ -1226,14 +1244,14 @@ removeMcpDetailsBtn?.addEventListener("click", async () => {
12261244
});
12271245

12281246
// Listen for MCP server updates from main process
1229-
ipcRenderer.on("mcp-servers-updated", (_event, servers: McpServer[]) => {
1230-
mcpServers = servers;
1231-
renderMcpServers();
1247+
ipcRenderer.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => {
1248+
// Only update if this is for the active session
1249+
if (sessionId === activeSessionId) {
1250+
mcpServers = servers;
1251+
renderMcpServers();
1252+
}
12321253
});
12331254

1234-
// Load MCP servers on startup
1235-
loadMcpServers();
1236-
12371255
// Settings Modal handling
12381256
const settingsModal = document.getElementById("settings-modal");
12391257
const openSettingsBtn = document.getElementById("open-settings");

0 commit comments

Comments
 (0)