Files
NetMesh/electron/bridges/externalMcpController.cjs

584 lines
18 KiB
JavaScript
Raw Normal View History

"use strict";
const crypto = require("node:crypto");
const {
getExternalMcpDiscoveryFilePath,
getExternalMcpLauncherPath,
EXTERNAL_MCP_CHAT_SESSION_ID,
buildDiscoveryEnv,
} = require("../cli/externalMcpDiscoveryPath.cjs");
const {
writeExternalDiscovery,
removeExternalDiscovery,
} = require("../cli/externalMcpDiscovery.cjs");
const { createExternalMcpCodexSetup } = require("./externalMcp/codexSetup.cjs");
const { createExternalMcpClaudeSetup } = require("./externalMcp/claudeSetup.cjs");
const { createExternalMcpGrokSetup } = require("./externalMcp/grokSetup.cjs");
const {
DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES,
normalizeSessionIdleTimeoutMinutes,
} = require("./mcpServerBridge/sessionIdleManager.cjs");
const EXTERNAL_MCP_MODE_TEMPORARY = "temporary";
const EXTERNAL_MCP_MODE_PERSISTENT = "persistent";
const DEFAULT_IDLE_TIMEOUT_MINUTES = 10;
const MIN_IDLE_TIMEOUT_MINUTES = 1;
const MAX_IDLE_TIMEOUT_MINUTES = 24 * 60;
function normalizeMode(value) {
return value === EXTERNAL_MCP_MODE_PERSISTENT
? EXTERNAL_MCP_MODE_PERSISTENT
: EXTERNAL_MCP_MODE_TEMPORARY;
}
function normalizeIdleTimeoutMinutes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_IDLE_TIMEOUT_MINUTES;
return Math.min(
MAX_IDLE_TIMEOUT_MINUTES,
Math.max(MIN_IDLE_TIMEOUT_MINUTES, Math.round(parsed)),
);
}
function createExternalMcpController(options = {}) {
const deps = {
mcpServerBridge: options.mcpServerBridge || null,
getDiscoveryFilePath: options.getDiscoveryFilePath || getExternalMcpDiscoveryFilePath,
getLauncherPath: options.getLauncherPath || getExternalMcpLauncherPath,
writeDiscovery: options.writeDiscovery || writeExternalDiscovery,
removeDiscovery: options.removeDiscovery || removeExternalDiscovery,
createCodexSetup: options.createCodexSetup || createExternalMcpCodexSetup,
createClaudeSetup: options.createClaudeSetup || createExternalMcpClaudeSetup,
createGrokSetup: options.createGrokSetup || createExternalMcpGrokSetup,
randomBytes: options.randomBytes || ((size) => crypto.randomBytes(size)),
Date: options.Date || Date,
setTimeout: options.setTimeout || setTimeout,
clearTimeout: options.clearTimeout || clearTimeout,
chatSessionId: options.chatSessionId || EXTERNAL_MCP_CHAT_SESSION_ID,
};
let discoveryFilePath = null;
let enabled = false;
let state = "disabled";
let error = null;
let mode = EXTERNAL_MCP_MODE_TEMPORARY;
let idleTimeoutMinutes = DEFAULT_IDLE_TIMEOUT_MINUTES;
let sessionIdleTimeoutMinutes = DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
let lastActivityAt = null;
let idleExpiresAt = null;
let idleTimer = null;
let startPromise = null;
let stopPromise = null;
let setEnabledChain = Promise.resolve();
let startGeneration = 0;
let lastKnownPort = null;
let lastKnownToken = null;
let sessionSyncHandler = null;
let codexSetup = null;
let claudeSetup = null;
let grokSetup = null;
function getNow() {
return deps.Date.now();
}
function clearIdleTimer() {
if (!idleTimer) return;
deps.clearTimeout(idleTimer);
idleTimer = null;
}
function scheduleIdleShutdown() {
clearIdleTimer();
if (!enabled || state !== "running" || mode !== EXTERNAL_MCP_MODE_TEMPORARY) {
idleExpiresAt = null;
return;
}
const now = getNow();
if (!lastActivityAt) lastActivityAt = now;
idleExpiresAt = lastActivityAt + idleTimeoutMinutes * 60 * 1000;
const delayMs = Math.max(0, idleExpiresAt - now);
idleTimer = deps.setTimeout(() => {
idleTimer = null;
void setEnabled(false);
}, delayMs);
}
function recordActivity() {
if (!enabled) return;
lastActivityAt = getNow();
scheduleIdleShutdown();
}
function ensureClientSetup() {
const launcherPath = deps.getLauncherPath() || null;
const discoveryEnv = buildDiscoveryEnv(discoveryFilePath);
if (!codexSetup) {
codexSetup = deps.createCodexSetup({ launcherPath, discoveryEnv });
}
if (!claudeSetup) {
claudeSetup = deps.createClaudeSetup({ launcherPath, discoveryEnv });
}
if (!grokSetup) {
grokSetup = deps.createGrokSetup({ launcherPath, discoveryEnv });
}
}
function getBridge() {
return deps.mcpServerBridge;
}
function getExposedSessionCount() {
const bridge = getBridge();
if (!bridge?.getScopedSessionIds) return 0;
return bridge.getScopedSessionIds(deps.chatSessionId).length;
}
function buildStatus() {
const bridge = getBridge();
const hostRunning = Boolean(bridge && typeof bridge.getOrCreateHost === "function" && lastKnownPort);
return {
ok: true,
enabled,
state,
host: "127.0.0.1",
port: lastKnownPort,
discoveryPath: discoveryFilePath || null,
launcherPath: deps.getLauncherPath() || null,
chatSessionId: deps.chatSessionId,
exposedSessionCount: getExposedSessionCount(),
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
lastActivityAt,
idleExpiresAt,
permissionMode: bridge?.getPermissionMode?.() || "confirm",
hostRunning,
error,
};
}
function writeDiscoveryFromBridge() {
const bridge = getBridge();
if (!discoveryFilePath || !bridge) return null;
// Prefer live host credentials from bridge module state via buildMcpServerConfig side channel.
// getOrCreateHost already started; read port/token from discovery write inputs we track.
if (lastKnownPort == null || !lastKnownToken) return null;
return deps.writeDiscovery(discoveryFilePath, {
host: "127.0.0.1",
port: lastKnownPort,
token: lastKnownToken,
pid: process.pid,
permissionMode: bridge.getPermissionMode?.() || "confirm",
chatSessionId: deps.chatSessionId,
});
}
async function resolveHostCredentials() {
const bridge = getBridge();
if (!bridge?.getOrCreateHost) {
throw new Error("MCP bridge is unavailable.");
}
const port = await bridge.getOrCreateHost();
// External MCP uses a dedicated token (rotated on enable) so disable can
// invalidate discovery without rotating the shared Catty/CLI TCP token.
const token = typeof bridge.issueExternalMcpAuthToken === "function"
? bridge.issueExternalMcpAuthToken()
: (typeof bridge.getExternalMcpAuthToken === "function" ? bridge.getExternalMcpAuthToken() : "");
if (!port || !token) {
throw new Error("Failed to resolve MCP bridge port/token for external mode.");
}
lastKnownPort = port;
lastKnownToken = token;
return { port, token };
}
async function startRuntime() {
if (!discoveryFilePath) {
throw new Error("External MCP discovery path is not configured.");
}
await resolveHostCredentials();
if (!enabled) {
throw new Error("External MCP was disabled during startup.");
}
writeDiscoveryFromBridge();
lastActivityAt = getNow();
if (typeof sessionSyncHandler === "function") {
await sessionSyncHandler();
}
}
async function stopActiveRuntime() {
clearIdleTimer();
idleExpiresAt = null;
lastKnownPort = null;
lastKnownToken = null;
if (discoveryFilePath) {
deps.removeDiscovery(discoveryFilePath);
}
const bridge = getBridge();
// Soft revoke: rotate/clear the external token and reject reserved-scope
// RPCs while disabled. Keep TCP sockets alive so long-lived stdio clients
// can resume after re-enable without restarting Codex/Claude/Grok.
if (typeof bridge?.revokeExternalMcpAuthToken === "function") {
try {
bridge.revokeExternalMcpAuthToken();
} catch {
// Best-effort token revoke.
}
}
if (typeof bridge?.cancelPtyExecsForSession === "function") {
try {
bridge.cancelPtyExecsForSession(deps.chatSessionId);
} catch {
// Best-effort exec cancel.
}
}
if (typeof bridge?.cancelBackgroundJobsForSession === "function") {
try {
bridge.cancelBackgroundJobsForSession(deps.chatSessionId);
} catch {
// Best-effort background job cancel.
}
}
if (typeof bridge?.cancelWorkerBackgroundJobsForSession === "function") {
try {
bridge.cancelWorkerBackgroundJobsForSession(deps.chatSessionId);
} catch {
// Best-effort worker job cancel.
}
}
if (typeof bridge?.cancelSftpOpsForSession === "function") {
try {
await bridge.cancelSftpOpsForSession(deps.chatSessionId);
} catch {
// Best-effort SFTP cancel.
}
}
if (typeof bridge?.setChatSessionCancelled === "function") {
try {
bridge.setChatSessionCancelled(deps.chatSessionId, true);
} catch {
// Best-effort cancel flag.
}
}
if (typeof bridge?.clearPendingApprovals === "function") {
try {
bridge.clearPendingApprovals(deps.chatSessionId);
} catch {
// Best-effort approval revoke.
}
}
if (bridge?.cleanupScopedMetadata) {
await bridge.cleanupScopedMetadata(deps.chatSessionId);
}
// cleanupScopedMetadata clears cancelledChatSessions; re-assert cancel so
// soft-revoke still blocks in-flight external work after metadata wipe.
if (typeof bridge?.setChatSessionCancelled === "function") {
try {
bridge.setChatSessionCancelled(deps.chatSessionId, true);
} catch {
// Best-effort cancel flag.
}
}
}
async function setEnabledUnlocked(nextEnabled) {
const wantEnabled = Boolean(nextEnabled);
enabled = wantEnabled;
if (!wantEnabled) {
startGeneration += 1;
if (startPromise) {
state = "disabled";
error = null;
await startPromise.catch(() => {});
}
if (stopPromise) {
await stopPromise;
}
state = "disabled";
error = null;
stopPromise = stopActiveRuntime().finally(() => {
stopPromise = null;
});
await stopPromise;
return buildStatus();
}
if (state === "running" && lastKnownPort != null && lastKnownToken) {
writeDiscoveryFromBridge();
scheduleIdleShutdown();
return buildStatus();
}
if (stopPromise) {
await stopPromise;
}
if (!enabled) {
return buildStatus();
}
if (startPromise) {
await startPromise.catch(() => {});
if (!enabled) {
return buildStatus();
}
if (state === "running" && lastKnownPort != null && lastKnownToken) {
writeDiscoveryFromBridge();
scheduleIdleShutdown();
return buildStatus();
}
}
const generation = ++startGeneration;
state = "starting";
error = null;
const bridgeForEnable = getBridge();
if (typeof bridgeForEnable?.setChatSessionCancelled === "function") {
try {
bridgeForEnable.setChatSessionCancelled(deps.chatSessionId, false);
} catch {
// Best-effort clear of a previous disable cancel flag.
}
}
startPromise = startRuntime()
.then(() => {
if (!enabled || generation !== startGeneration) {
state = "disabled";
error = null;
return;
}
state = "running";
scheduleIdleShutdown();
})
.catch(async (startError) => {
if (generation !== startGeneration) return;
error = startError?.message || String(startError);
enabled = false;
state = "disabled";
try {
await stopActiveRuntime();
} catch {
// Ignore cleanup failures while surfacing the startup error.
}
})
.finally(() => {
if (generation === startGeneration) {
startPromise = null;
}
});
await startPromise;
return buildStatus();
}
function setEnabled(nextEnabled) {
const run = () => setEnabledUnlocked(nextEnabled);
const result = setEnabledChain.then(run, run);
setEnabledChain = result.then(() => {}, () => {});
return result;
}
function setConfig(config = {}) {
const nextMode = config.mode == null ? mode : normalizeMode(config.mode);
const nextIdleTimeoutMinutes = config.idleTimeoutMinutes == null
? idleTimeoutMinutes
: normalizeIdleTimeoutMinutes(config.idleTimeoutMinutes);
const nextSessionIdleTimeoutMinutes = config.sessionIdleTimeoutMinutes == null
? sessionIdleTimeoutMinutes
: normalizeSessionIdleTimeoutMinutes(config.sessionIdleTimeoutMinutes);
const modeChanged = nextMode !== mode;
const timeoutChanged = nextIdleTimeoutMinutes !== idleTimeoutMinutes;
mode = nextMode;
idleTimeoutMinutes = nextIdleTimeoutMinutes;
sessionIdleTimeoutMinutes = nextSessionIdleTimeoutMinutes;
getBridge()?.setSessionIdleTimeoutMinutes?.(sessionIdleTimeoutMinutes);
if ((modeChanged || timeoutChanged) && enabled && state === "running") {
lastActivityAt = getNow();
scheduleIdleShutdown();
}
return buildStatus();
}
function getStatus() {
return buildStatus();
}
function isEnabled() {
// Accept clients while starting/running as soon as the switch is on.
// Discovery may be written before state flips to "running". Do not treat
// error/disabled as enabled after cleanup revoked discovery/sockets.
return enabled && (state === "starting" || state === "running");
}
function getChatSessionId() {
return deps.chatSessionId;
}
function setSessionSyncHandler(handler) {
sessionSyncHandler = typeof handler === "function" ? handler : null;
}
function onBridgeHostReady({ port }) {
if (!isEnabled()) return;
// Host-ready reports the shared Catty/CLI TCP token — never write that into
// External discovery. Only refresh with an already-issued external token;
// do not issue here or cold-start enable double-rotates before
// resolveHostCredentials runs.
if (port) lastKnownPort = port;
const bridge = getBridge();
const externalToken = typeof bridge?.getExternalMcpAuthToken === "function"
? bridge.getExternalMcpAuthToken()
: null;
if (!externalToken) return;
lastKnownToken = externalToken;
if (discoveryFilePath && lastKnownPort && lastKnownToken) {
writeDiscoveryFromBridge();
}
}
function onPermissionModeChanged() {
if (isEnabled() && state === "running") {
writeDiscoveryFromBridge();
}
}
function init(initOptions = {}) {
if (initOptions.mcpServerBridge) {
deps.mcpServerBridge = initOptions.mcpServerBridge;
}
discoveryFilePath = initOptions.discoveryFilePath
|| deps.getDiscoveryFilePath(
initOptions.userDataDir ? { userDataDir: initOptions.userDataDir } : {},
);
// Rotate any stale discovery from a previous process.
if (discoveryFilePath) {
deps.removeDiscovery(discoveryFilePath);
}
// Recreate client setup helpers so they pick up the resolved discovery path.
codexSetup = null;
claudeSetup = null;
grokSetup = null;
ensureClientSetup();
}
function cleanup() {
clearIdleTimer();
if (discoveryFilePath) {
deps.removeDiscovery(discoveryFilePath);
}
enabled = false;
state = "disabled";
lastKnownPort = null;
lastKnownToken = null;
}
async function getCodexStatus() {
ensureClientSetup();
return await codexSetup.getStatus();
}
async function addToCodex() {
ensureClientSetup();
return await codexSetup.addToCodex();
}
async function getClaudeStatus() {
ensureClientSetup();
return await claudeSetup.getStatus();
}
async function addToClaude() {
ensureClientSetup();
return await claudeSetup.addToClaude();
}
async function getGrokStatus() {
ensureClientSetup();
return await grokSetup.getStatus();
}
async function addToGrok() {
ensureClientSetup();
return await grokSetup.addToGrok();
}
function registerHandlers(ipcMain, validateSender) {
const guard = typeof validateSender === "function"
? validateSender
: () => true;
ipcMain.handle("netcatty:external-mcp:get-status", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return getStatus();
});
ipcMain.handle("netcatty:external-mcp:set-enabled", async (event, payload) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await setEnabled(Boolean(payload?.enabled));
});
ipcMain.handle("netcatty:external-mcp:set-config", async (event, payload) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return setConfig(payload || {});
});
ipcMain.handle("netcatty:external-mcp:codex:get-status", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await getCodexStatus();
});
ipcMain.handle("netcatty:external-mcp:codex:add", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await addToCodex();
});
ipcMain.handle("netcatty:external-mcp:claude:get-status", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await getClaudeStatus();
});
ipcMain.handle("netcatty:external-mcp:claude:add", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await addToClaude();
});
ipcMain.handle("netcatty:external-mcp:grok:get-status", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await getGrokStatus();
});
ipcMain.handle("netcatty:external-mcp:grok:add", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return await addToGrok();
});
}
return {
init,
cleanup,
setEnabled,
setConfig,
getStatus,
isEnabled,
getChatSessionId,
setSessionSyncHandler,
recordActivity,
onBridgeHostReady,
onPermissionModeChanged,
registerHandlers,
getCodexStatus,
addToCodex,
getClaudeStatus,
addToClaude,
getGrokStatus,
addToGrok,
EXTERNAL_MCP_CHAT_SESSION_ID: deps.chatSessionId,
};
}
module.exports = {
createExternalMcpController,
normalizeMode,
normalizeIdleTimeoutMinutes,
EXTERNAL_MCP_MODE_TEMPORARY,
EXTERNAL_MCP_MODE_PERSISTENT,
DEFAULT_IDLE_TIMEOUT_MINUTES,
EXTERNAL_MCP_CHAT_SESSION_ID,
};