308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { pathToFileURL } from "node:url";
|
|
import { realpathSync } from "node:fs";
|
|
|
|
import {
|
|
serveStdio,
|
|
type StdioServerHandle,
|
|
} from "@modelcontextprotocol/server/stdio";
|
|
|
|
import { parseConfig } from "./config.js";
|
|
import { createBinarySession } from "./application/runtime.js";
|
|
import { createServer } from "./server/createServer.js";
|
|
import { createLogger } from "./logger.js";
|
|
import { projectAnalysisError } from "./domain/errors.js";
|
|
import { readProjectPermissionStore } from "./application/ProjectPermissionStore.js";
|
|
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
|
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
|
import { CdpElectronProvider } from "./browser/CdpElectronProvider.js";
|
|
import type { PermissionGrant } from "./domain/permissionPolicy.js";
|
|
|
|
const MCP_CONNECTION_LOST =
|
|
"REA lost its MCP client connection. Restart REA from your MCP client.";
|
|
const MCP_CONNECTION_START_FAILED =
|
|
"REA could not start its MCP connection. Restart REA from your MCP client; run `rea doctor` if it fails again.";
|
|
const MCP_SHUTDOWN_FAILED =
|
|
"REA could not close cleanly. End the REA process before starting it again.";
|
|
const SERVER_START_FAILED =
|
|
"REA could not start. Run `rea doctor`, then restart REA from your MCP client.";
|
|
|
|
/** Process boundaries owned by the MCP executable adapter. */
|
|
interface RuntimeDependencies {
|
|
readonly env: NodeJS.ProcessEnv;
|
|
readonly serve: typeof serveStdio;
|
|
readonly writeStderr: (text: string) => void;
|
|
readonly setExitCode: (code: number) => void;
|
|
readonly registerShutdown: (handler: () => void) => () => void;
|
|
readonly registerReload?: (handler: () => void) => () => void;
|
|
readonly createServer?: typeof createServer;
|
|
readonly readProjectPermissionStore?: typeof readProjectPermissionStore;
|
|
}
|
|
|
|
const runtimeDependencies = (): RuntimeDependencies => ({
|
|
env: process.env,
|
|
serve: serveStdio,
|
|
writeStderr: (text) => process.stderr.write(text),
|
|
setExitCode: (code) => {
|
|
process.exitCode = code;
|
|
},
|
|
registerShutdown: (handler) => {
|
|
process.once("SIGINT", handler);
|
|
process.once("SIGTERM", handler);
|
|
process.stdin.once("end", handler);
|
|
process.stdin.once("close", handler);
|
|
return () => {
|
|
process.off("SIGINT", handler);
|
|
process.off("SIGTERM", handler);
|
|
process.stdin.off("end", handler);
|
|
process.stdin.off("close", handler);
|
|
};
|
|
},
|
|
registerReload: (handler) => {
|
|
process.on("SIGHUP", handler);
|
|
return () => process.off("SIGHUP", handler);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
|
|
* The adapter owns its BinarySession for the process lifetime; EOF and process
|
|
* signals close bridge resources without assuming ownership of the Hopper app.
|
|
*/
|
|
export const run = async (
|
|
dependencies: RuntimeDependencies = runtimeDependencies(),
|
|
): Promise<number> => {
|
|
const config = parseConfig(dependencies.env);
|
|
if (!config.ok) {
|
|
dependencies.writeStderr(`${projectAnalysisError(config.error).message}\n`);
|
|
return 1;
|
|
}
|
|
const logger = createLogger("mcp", config.value.logLevel);
|
|
const serverLogger = logger.child({ layer: "server" });
|
|
const permissionAuthority = await loadConfiguredPermissionAuthority(
|
|
config.value,
|
|
);
|
|
if (!permissionAuthority.ok) {
|
|
dependencies.writeStderr(
|
|
`${projectAnalysisError(permissionAuthority.error).message}\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
const session = createBinarySession(config.value, logger);
|
|
const browserObservation = new CdpBrowserProvider();
|
|
const electronObservation = new CdpElectronProvider();
|
|
if (config.value.hopperTargetPath !== undefined) {
|
|
const opened = await session.open(config.value.hopperTargetPath, {
|
|
targetKind: config.value.hopperTargetKind,
|
|
});
|
|
if (!opened.ok) {
|
|
await session.close();
|
|
serverLogger.error(
|
|
{ errorTag: opened.error._tag },
|
|
"Initial target failed to open",
|
|
);
|
|
dependencies.writeStderr(
|
|
`${projectAnalysisError(opened.error).message}\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
}
|
|
let currentConfig = config.value;
|
|
const runtimeProcessPolicy = {
|
|
...config.value.processExecutionPolicy,
|
|
executableRoots: [...config.value.processExecutionPolicy.executableRoots],
|
|
workingRoots: [...config.value.processExecutionPolicy.workingRoots],
|
|
allowedEnvironment: [
|
|
...config.value.processExecutionPolicy.allowedEnvironment,
|
|
],
|
|
};
|
|
const runtimeEvidencePolicy = {
|
|
...config.value.evidenceFilePolicy,
|
|
roots: [...config.value.evidenceFilePolicy.roots],
|
|
};
|
|
const runtimeSnapshotPolicy = {
|
|
...config.value.analysisSnapshotFilePolicy,
|
|
roots: [...config.value.analysisSnapshotFilePolicy.roots],
|
|
};
|
|
const runtimeInvestigationRoots = [...config.value.investigationInputRoots];
|
|
const runtimeJavascriptReplayPolicy = {
|
|
...config.value.javascriptReplayPolicy,
|
|
roots: [...config.value.javascriptReplayPolicy.roots],
|
|
};
|
|
const runtimeManagedRuntimePolicy = {
|
|
...config.value.managedRuntimePolicy,
|
|
roots: [...config.value.managedRuntimePolicy.roots],
|
|
};
|
|
const liveServers = new Set<ReturnType<typeof createServer>>();
|
|
let handle: StdioServerHandle;
|
|
try {
|
|
handle = dependencies.serve(
|
|
() => {
|
|
const server = (dependencies.createServer ?? createServer)(
|
|
session,
|
|
session,
|
|
{
|
|
logger,
|
|
processPolicy: runtimeProcessPolicy,
|
|
evidenceFilePolicy: runtimeEvidencePolicy,
|
|
investigationInputRoots: runtimeInvestigationRoots,
|
|
analysisSnapshotFilePolicy: runtimeSnapshotPolicy,
|
|
permissionAuthority: permissionAuthority.value,
|
|
browserObservation,
|
|
electronObservation,
|
|
artifactIntegrityContinueEnabled: () =>
|
|
currentConfig.artifactIntegrityContinueEnabled,
|
|
javascriptReplayPolicy: runtimeJavascriptReplayPolicy,
|
|
managedRuntimePolicy: runtimeManagedRuntimePolicy,
|
|
availabilityPolicy: () => ({
|
|
processCaptureEnabled:
|
|
currentConfig.processExecutionPolicy.enabled,
|
|
evidenceFileRoots: currentConfig.evidenceFilePolicy.roots.length,
|
|
browserObservationEnabled:
|
|
currentConfig.browserObservationEnabled &&
|
|
currentConfig.browserCdpEndpoints.length > 0 &&
|
|
currentConfig.browserAllowedOrigins.length > 0,
|
|
electronObservationEnabled:
|
|
currentConfig.electronObservationEnabled &&
|
|
currentConfig.electronCdpEndpoints.length > 0 &&
|
|
currentConfig.electronFileRoots.length > 0,
|
|
javascriptReplayEnabled:
|
|
currentConfig.javascriptReplayPolicy.enabled,
|
|
managedRuntimeEnabled: currentConfig.managedRuntimePolicy.enabled,
|
|
}),
|
|
},
|
|
);
|
|
liveServers.add(server);
|
|
return server;
|
|
},
|
|
{
|
|
onerror: () => {
|
|
serverLogger.error(MCP_CONNECTION_LOST);
|
|
dependencies.writeStderr(`${MCP_CONNECTION_LOST}\n`);
|
|
},
|
|
},
|
|
);
|
|
} catch {
|
|
await session.close();
|
|
serverLogger.error(MCP_CONNECTION_START_FAILED);
|
|
dependencies.writeStderr(`${MCP_CONNECTION_START_FAILED}\n`);
|
|
return 1;
|
|
}
|
|
|
|
let reloadQueue = Promise.resolve();
|
|
const unregisterReload =
|
|
dependencies.registerReload?.(() => {
|
|
const refreshed = parseConfig(dependencies.env);
|
|
if (!refreshed.ok) {
|
|
serverLogger.error("Reloaded permission policy is invalid");
|
|
return;
|
|
}
|
|
reloadQueue = reloadQueue.then(async () => {
|
|
let projectGrants: readonly PermissionGrant[] = [];
|
|
if (
|
|
refreshed.value.permissionProjectRoot !== undefined &&
|
|
refreshed.value.permissionProjectStore !== undefined
|
|
) {
|
|
const project = await (
|
|
dependencies.readProjectPermissionStore ??
|
|
readProjectPermissionStore
|
|
)(
|
|
refreshed.value.permissionProjectStore,
|
|
refreshed.value.permissionProjectRoot,
|
|
);
|
|
if (!project.ok) {
|
|
serverLogger.error("Reloaded project grants could not be read");
|
|
return;
|
|
}
|
|
projectGrants = project.value?.grants ?? [];
|
|
}
|
|
const reloaded =
|
|
await permissionAuthority.value.replaceConfiguredPolicy({
|
|
ceilings: refreshed.value.permissionCeilings,
|
|
administratorGrants: refreshed.value.administratorPermissionGrants,
|
|
projectGrants,
|
|
});
|
|
if (!reloaded.ok) {
|
|
serverLogger.error("Reloaded permission policy could not be applied");
|
|
return;
|
|
}
|
|
currentConfig = refreshed.value;
|
|
Object.assign(
|
|
runtimeProcessPolicy,
|
|
refreshed.value.processExecutionPolicy,
|
|
);
|
|
Object.assign(
|
|
runtimeEvidencePolicy,
|
|
refreshed.value.evidenceFilePolicy,
|
|
);
|
|
Object.assign(
|
|
runtimeSnapshotPolicy,
|
|
refreshed.value.analysisSnapshotFilePolicy,
|
|
);
|
|
runtimeInvestigationRoots.splice(
|
|
0,
|
|
runtimeInvestigationRoots.length,
|
|
...refreshed.value.investigationInputRoots,
|
|
);
|
|
Object.assign(
|
|
runtimeJavascriptReplayPolicy,
|
|
refreshed.value.javascriptReplayPolicy,
|
|
);
|
|
Object.assign(
|
|
runtimeManagedRuntimePolicy,
|
|
refreshed.value.managedRuntimePolicy,
|
|
);
|
|
for (const server of liveServers) server.sendToolListChanged();
|
|
});
|
|
}) ?? (() => undefined);
|
|
|
|
let shutdownPromise: Promise<void> | undefined;
|
|
let unregisterShutdown = (): void => undefined;
|
|
const shutdown = (): Promise<void> => {
|
|
shutdownPromise ??= (async () => {
|
|
unregisterReload();
|
|
unregisterShutdown();
|
|
await handle.close();
|
|
await session.close();
|
|
permissionAuthority.value.clearSessionGrants();
|
|
})();
|
|
return shutdownPromise;
|
|
};
|
|
const requestShutdown = (): void => {
|
|
shutdown().catch(() => {
|
|
dependencies.setExitCode(1);
|
|
serverLogger.error(MCP_SHUTDOWN_FAILED);
|
|
dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`);
|
|
});
|
|
};
|
|
|
|
unregisterShutdown = dependencies.registerShutdown(requestShutdown);
|
|
return 0;
|
|
};
|
|
|
|
/** Run the executable boundary without exposing an unexpected startup cause. */
|
|
export const runEntrypoint = async (
|
|
start: () => Promise<number> = () => run(),
|
|
writeStderr: (text: string) => void = (text) => process.stderr.write(text),
|
|
setExitCode: (code: number) => void = (code) => {
|
|
process.exitCode = code;
|
|
},
|
|
): Promise<void> => {
|
|
try {
|
|
setExitCode(await start());
|
|
} catch {
|
|
writeStderr(`${SERVER_START_FAILED}\n`);
|
|
setExitCode(1);
|
|
}
|
|
};
|
|
|
|
const entryPath = process.argv[1];
|
|
if (
|
|
entryPath !== undefined &&
|
|
pathToFileURL(realpathSync(entryPath)).href === import.meta.url
|
|
) {
|
|
void runEntrypoint();
|
|
}
|