816 lines
27 KiB
TypeScript
816 lines
27 KiB
TypeScript
import { execCli } from '@main/utils/childProcess';
|
|
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
|
import {
|
|
getClaudeBasePath,
|
|
getMcpConfigsBasePath,
|
|
getMcpServerBasePath,
|
|
} from '@main/utils/pathDecoder';
|
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { resolveTeamMemberMcpScopes } from '@shared/utils/teamMemberMcpPolicy';
|
|
import { randomUUID } from 'crypto';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import { McpConfigStateReader } from '../extensions/runtime/McpConfigStateReader';
|
|
|
|
import { atomicWriteAsync } from './atomicWrite';
|
|
|
|
import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
|
|
|
|
export interface McpLaunchSpec {
|
|
command: string;
|
|
args: string[];
|
|
env?: Record<string, string>;
|
|
}
|
|
|
|
export interface McpLaunchSpecResolveProgress {
|
|
phase: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface McpLaunchSpecResolveOptions {
|
|
onProgress?: (progress: McpLaunchSpecResolveProgress) => void;
|
|
}
|
|
|
|
interface WriteMcpConfigOptions {
|
|
mcpPolicy?: TeamMemberMcpPolicy;
|
|
controlApiBaseUrl?: string | null;
|
|
}
|
|
|
|
const MCP_SERVER_NAME = 'agent-teams';
|
|
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
|
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
|
|
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
|
|
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
|
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
|
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
|
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
|
const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
|
|
const MIN_MCP_NODE_MAJOR_VERSION = 20;
|
|
const NODE_RUNTIME_PROBE_SCRIPT =
|
|
'process.stdout.write(JSON.stringify({execPath:process.execPath,version:process.versions.node}))';
|
|
/**
|
|
* Stale configs older than this are removed on startup (best-effort).
|
|
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
|
|
* --mcp-config paths, so shorter TTLs risk deleting configs still needed
|
|
* by long-running or retrying sessions in other app instances.
|
|
*/
|
|
const MCP_CONFIG_STALE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
type McpServerConfig = Record<string, unknown>;
|
|
|
|
interface NodeRuntimeProbeMetadata {
|
|
path: string;
|
|
version: string;
|
|
}
|
|
|
|
const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'project', 'local'];
|
|
|
|
function isPackagedApp(): boolean {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { app } = require('electron') as typeof import('electron');
|
|
return app.isPackaged;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getAppVersion(): string {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const { app } = require('electron') as typeof import('electron');
|
|
return app.getVersion();
|
|
} catch {
|
|
return '0.0.0-dev';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In a packaged Electron build the mcp-server bundle lives under
|
|
* `process.resourcesPath/mcp-server/index.js` (copied via extraResources).
|
|
* This is the fallback location when the stable copy is unavailable.
|
|
*/
|
|
function getPackagedServerEntry(): string {
|
|
return path.join(process.resourcesPath, 'mcp-server', 'index.js');
|
|
}
|
|
|
|
function getWorkspaceRoot(): string {
|
|
return process.cwd();
|
|
}
|
|
|
|
function shouldUsePackagedElectronNodeRuntime(): boolean {
|
|
return (
|
|
isPackagedApp() && typeof process.execPath === 'string' && process.execPath.trim().length > 0
|
|
);
|
|
}
|
|
|
|
function getPackagedElectronNodeEnv(): Record<string, string> {
|
|
return {
|
|
[ELECTRON_RUN_AS_NODE_ENV]: '1',
|
|
};
|
|
}
|
|
|
|
function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec {
|
|
return {
|
|
command: process.execPath.trim(),
|
|
args: [entry],
|
|
env: getPackagedElectronNodeEnv(),
|
|
};
|
|
}
|
|
|
|
function getWorkspaceMcpServerDir(): string {
|
|
return path.join(getWorkspaceRoot(), 'mcp-server');
|
|
}
|
|
|
|
function getBuiltServerEntry(): string {
|
|
return path.join(getWorkspaceMcpServerDir(), 'dist', 'index.js');
|
|
}
|
|
|
|
function getSourceServerEntry(): string {
|
|
return path.join(getWorkspaceMcpServerDir(), 'src', 'index.ts');
|
|
}
|
|
|
|
function getWorkspaceTsxPackageJsonCandidates(): string[] {
|
|
return [
|
|
path.join(getWorkspaceMcpServerDir(), 'node_modules', 'tsx', 'package.json'),
|
|
path.join(getWorkspaceRoot(), 'node_modules', 'tsx', 'package.json'),
|
|
];
|
|
}
|
|
|
|
function resolvePackageBin(
|
|
packageJsonPath: string,
|
|
binName: string,
|
|
packageJsonRaw: string
|
|
): string | null {
|
|
const packageJson = JSON.parse(packageJsonRaw) as { bin?: string | Record<string, string> };
|
|
const bin = typeof packageJson.bin === 'string' ? packageJson.bin : packageJson.bin?.[binName];
|
|
if (!bin) return null;
|
|
return path.resolve(path.dirname(packageJsonPath), bin);
|
|
}
|
|
|
|
async function pathExists(targetPath: string): Promise<boolean> {
|
|
try {
|
|
await fs.promises.access(targetPath, fs.constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function resolveWorkspaceTsxCli(checked: string[]): Promise<string | null> {
|
|
for (const packageJsonPath of getWorkspaceTsxPackageJsonCandidates()) {
|
|
checked.push(packageJsonPath);
|
|
if (!(await pathExists(packageJsonPath))) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const tsxCli = resolvePackageBin(
|
|
packageJsonPath,
|
|
'tsx',
|
|
await fs.promises.readFile(packageJsonPath, 'utf8')
|
|
);
|
|
if (!tsxCli) {
|
|
logger.warn(`tsx package has no bin.tsx entry at ${packageJsonPath}`);
|
|
continue;
|
|
}
|
|
|
|
checked.push(tsxCli);
|
|
if (await pathExists(tsxCli)) {
|
|
return tsxCli;
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`Failed to resolve tsx CLI from ${packageJsonPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function shouldRetryMcpConfigRemoval(error: NodeJS.ErrnoException): boolean {
|
|
return error.code === 'EPERM' || error.code === 'EBUSY';
|
|
}
|
|
|
|
async function waitForRetry(delayMs: number): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
}
|
|
|
|
/** Check that both index.js and package.json exist in a directory. */
|
|
async function hasValidServerCopy(dir: string): Promise<boolean> {
|
|
return (
|
|
(await pathExists(path.join(dir, 'index.js'))) &&
|
|
(await pathExists(path.join(dir, 'package.json')))
|
|
);
|
|
}
|
|
|
|
let _resolvedNodePath: string | undefined;
|
|
let _packagedElectronNodeRuntimeProbe: { ok: true } | { ok: false; error: unknown } | undefined;
|
|
|
|
export function clearResolvedNodePathForTests(): void {
|
|
_resolvedNodePath = undefined;
|
|
_packagedElectronNodeRuntimeProbe = undefined;
|
|
}
|
|
|
|
function emitProgress(
|
|
options: McpLaunchSpecResolveOptions | undefined,
|
|
phase: string,
|
|
message: string
|
|
): void {
|
|
options?.onProgress?.({ phase, message });
|
|
}
|
|
|
|
function stringifyError(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
function looksLikeNodeBinaryPath(candidate: string | undefined): candidate is string {
|
|
if (!candidate?.trim()) {
|
|
return false;
|
|
}
|
|
return /^node(?:-\d+)?(?:\.exe)?$/i.test(path.basename(candidate.trim()));
|
|
}
|
|
|
|
function getNodeRuntimeCommandCandidates(): string[] {
|
|
const candidates = [
|
|
process.env.NODE_BINARY,
|
|
'node',
|
|
process.env.npm_node_execpath,
|
|
looksLikeNodeBinaryPath(process.execPath) ? process.execPath : undefined,
|
|
];
|
|
const seen = new Set<string>();
|
|
return candidates.flatMap((candidate) => {
|
|
const normalized = candidate?.trim();
|
|
if (!normalized || seen.has(normalized)) {
|
|
return [];
|
|
}
|
|
seen.add(normalized);
|
|
return [normalized];
|
|
});
|
|
}
|
|
|
|
function shouldPreferShellNodeProbe(): boolean {
|
|
if (process.platform === 'win32') {
|
|
return false;
|
|
}
|
|
|
|
const pathValue = process.env.PATH?.trim();
|
|
if (!pathValue) {
|
|
return true;
|
|
}
|
|
|
|
const minimalGuiPathEntries = new Set(['/usr/bin', '/bin', '/usr/sbin', '/sbin']);
|
|
const entries = pathValue
|
|
.split(path.delimiter)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
return (
|
|
entries.length > 0 && entries.every((entry) => minimalGuiPathEntries.has(path.resolve(entry)))
|
|
);
|
|
}
|
|
|
|
function mergePathValues(...values: (string | undefined)[]): string | undefined {
|
|
const seen = new Set<string>();
|
|
const merged: string[] = [];
|
|
for (const value of values) {
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
for (const segment of value.split(path.delimiter)) {
|
|
if (!segment || seen.has(segment)) {
|
|
continue;
|
|
}
|
|
seen.add(segment);
|
|
merged.push(segment);
|
|
}
|
|
}
|
|
return merged.length > 0 ? merged.join(path.delimiter) : undefined;
|
|
}
|
|
|
|
function parseNodeMajorVersion(version: string): number | null {
|
|
const match = /^v?(\d+)(?:\.|$)/.exec(version.trim());
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const major = Number.parseInt(match[1] ?? '', 10);
|
|
return Number.isFinite(major) ? major : null;
|
|
}
|
|
|
|
function parseNodeRuntimeProbeMetadata(stdout: string, command: string): NodeRuntimeProbeMetadata {
|
|
const trimmed = stdout.trim();
|
|
if (!trimmed) {
|
|
throw new Error(`${command} did not report Node.js runtime metadata`);
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
throw new Error(`${command} reported invalid Node.js runtime metadata`);
|
|
}
|
|
|
|
if (parsed === null || typeof parsed !== 'object') {
|
|
throw new Error(`${command} reported invalid Node.js runtime metadata`);
|
|
}
|
|
|
|
const metadata = parsed as { execPath?: unknown; version?: unknown };
|
|
const resolvedPath = typeof metadata.execPath === 'string' ? metadata.execPath.trim() : '';
|
|
if (!resolvedPath) {
|
|
throw new Error(`${command} did not report process.execPath`);
|
|
}
|
|
|
|
const version = typeof metadata.version === 'string' ? metadata.version.trim() : '';
|
|
if (!version) {
|
|
throw new Error(`${command} did not report process.versions.node`);
|
|
}
|
|
|
|
return { path: resolvedPath, version };
|
|
}
|
|
|
|
function assertSupportedMcpNodeRuntime(command: string, metadata: NodeRuntimeProbeMetadata): void {
|
|
const major = parseNodeMajorVersion(metadata.version);
|
|
if (major === null || major < MIN_MCP_NODE_MAJOR_VERSION) {
|
|
throw new Error(
|
|
`${command} resolved ${metadata.path} with Node.js ${metadata.version}; Agent Teams MCP requires Node.js ${MIN_MCP_NODE_MAJOR_VERSION}+`
|
|
);
|
|
}
|
|
}
|
|
|
|
function isWriteMcpConfigOptions(value: unknown): value is WriteMcpConfigOptions {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
('mcpPolicy' in value || 'controlApiBaseUrl' in value)
|
|
);
|
|
}
|
|
|
|
function buildNodeResolveEnv(shellEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
...shellEnv,
|
|
};
|
|
const mergedPath = mergePathValues(shellEnv.PATH, buildMergedCliPath(), process.env.PATH);
|
|
if (mergedPath) {
|
|
env.PATH = mergedPath;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
async function probeNodeRuntimePath(
|
|
env: NodeJS.ProcessEnv
|
|
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
|
|
let lastError: unknown = null;
|
|
for (const command of getNodeRuntimeCommandCandidates()) {
|
|
try {
|
|
const { stdout } = await execCli(command, ['-e', NODE_RUNTIME_PROBE_SCRIPT], {
|
|
encoding: 'utf-8',
|
|
timeout: NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
|
env,
|
|
});
|
|
const metadata = parseNodeRuntimeProbeMetadata(stdout, command);
|
|
assertSupportedMcpNodeRuntime(command, metadata);
|
|
return { ok: true, path: metadata.path };
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
|
|
}
|
|
|
|
async function probePackagedElectronNodeRuntime(
|
|
options?: McpLaunchSpecResolveOptions
|
|
): Promise<{ ok: true } | { ok: false; error: unknown }> {
|
|
if (_packagedElectronNodeRuntimeProbe) {
|
|
return _packagedElectronNodeRuntimeProbe;
|
|
}
|
|
|
|
emitProgress(options, 'electron-node-runtime', 'Checking bundled Electron Node runtime...');
|
|
try {
|
|
const { stdout } = await execCli(
|
|
process.execPath.trim(),
|
|
['-e', 'process.stdout.write("agent-teams-electron-node-ok")'],
|
|
{
|
|
encoding: 'utf-8',
|
|
timeout: ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
...getPackagedElectronNodeEnv(),
|
|
},
|
|
}
|
|
);
|
|
if (stdout.trim() !== 'agent-teams-electron-node-ok') {
|
|
throw new Error('Electron Node runtime probe did not return the expected marker');
|
|
}
|
|
_packagedElectronNodeRuntimeProbe = { ok: true };
|
|
} catch (error) {
|
|
_packagedElectronNodeRuntimeProbe = { ok: false, error };
|
|
}
|
|
return _packagedElectronNodeRuntimeProbe;
|
|
}
|
|
|
|
async function probeShellNodeRuntimePath(
|
|
options?: McpLaunchSpecResolveOptions
|
|
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
|
|
let shellEnv: NodeJS.ProcessEnv = {};
|
|
try {
|
|
shellEnv = await resolveInteractiveShellEnv({
|
|
source: 'mcp-node-runtime',
|
|
onProgress: options?.onProgress
|
|
? ({ phase, message }) => emitProgress(options, `shell-${phase}`, message)
|
|
: undefined,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(`Failed to resolve shell env before Node.js lookup: ${stringifyError(error)}`);
|
|
}
|
|
|
|
return probeNodeRuntimePath(buildNodeResolveEnv(shellEnv));
|
|
}
|
|
|
|
/**
|
|
* Find the real `node` binary path. In Electron, process.execPath is the
|
|
* Electron binary — NOT node — so we must resolve node separately.
|
|
* Uses the user's shell/enriched PATH so packaged GUI launches do not depend
|
|
* on the minimal Finder/Dock PATH.
|
|
*/
|
|
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
|
if (_resolvedNodePath) return _resolvedNodePath;
|
|
|
|
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
|
const preferShellNodeProbe = shouldPreferShellNodeProbe();
|
|
if (preferShellNodeProbe && !process.env.NODE_BINARY?.trim()) {
|
|
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
|
const shellProbe = await probeShellNodeRuntimePath(options);
|
|
if (shellProbe.ok) {
|
|
_resolvedNodePath = shellProbe.path;
|
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
|
return _resolvedNodePath;
|
|
}
|
|
}
|
|
|
|
const fastProbe = await probeNodeRuntimePath(buildNodeResolveEnv({}));
|
|
if (fastProbe.ok) {
|
|
_resolvedNodePath = fastProbe.path;
|
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
|
return _resolvedNodePath;
|
|
}
|
|
|
|
if (!preferShellNodeProbe) {
|
|
emitProgress(options, 'node-runtime-shell-fallback', 'Trying login shell Node.js runtime...');
|
|
const shellProbe = await probeShellNodeRuntimePath(options);
|
|
if (shellProbe.ok) {
|
|
_resolvedNodePath = shellProbe.path;
|
|
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
|
return _resolvedNodePath;
|
|
}
|
|
|
|
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
|
throw new Error(
|
|
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${
|
|
shellProbe.error ? stringifyError(shellProbe.error) : stringifyError(fastProbe.error)
|
|
}`
|
|
);
|
|
}
|
|
|
|
emitProgress(options, 'node-runtime-missing', 'Node.js runtime for MCP server was not found.');
|
|
throw new Error(
|
|
`Node.js runtime for Agent Teams MCP was not found. Ensure Node.js is installed and available from the login shell PATH. Last error: ${stringifyError(
|
|
fastProbe.error
|
|
)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* For packaged builds, copy the MCP server to a stable, writable location
|
|
* under userData so the server runs from a non-FUSE path (fixes AppImage).
|
|
*
|
|
* Uses a versioned subdirectory + atomic rename to avoid partial state:
|
|
* userData/mcp-server/<appVersion>/index.js
|
|
* userData/mcp-server/<appVersion>/package.json
|
|
*
|
|
* Returns the resolved index.js path (stable copy or resourcesPath fallback).
|
|
*/
|
|
async function resolvePackagedServerEntry(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
|
const fallbackEntry = getPackagedServerEntry();
|
|
if (!isPackagedApp()) return fallbackEntry;
|
|
|
|
emitProgress(options, 'packaged-server', 'Checking packaged MCP server...');
|
|
const appVersion = getAppVersion();
|
|
const baseDir = getMcpServerBasePath();
|
|
const finalDir = path.join(baseDir, appVersion);
|
|
const finalEntry = path.join(finalDir, 'index.js');
|
|
|
|
// Reuse existing valid copy
|
|
if (await hasValidServerCopy(finalDir)) {
|
|
emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...');
|
|
return finalEntry;
|
|
}
|
|
|
|
// Heal invalid finalDir (partial state from previous crash)
|
|
try {
|
|
if ((await pathExists(finalDir)) && !(await hasValidServerCopy(finalDir))) {
|
|
logger.warn(`Removing invalid MCP server copy at ${finalDir}`);
|
|
await fs.promises.rm(finalDir, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
/* best-effort heal */
|
|
}
|
|
|
|
try {
|
|
const sourceDir = path.join(process.resourcesPath, 'mcp-server');
|
|
if (!(await hasValidServerCopy(sourceDir))) {
|
|
logger.warn(`Packaged MCP server missing in resourcesPath: ${sourceDir}`);
|
|
return fallbackEntry;
|
|
}
|
|
|
|
emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...');
|
|
// Atomic: copy to temp dir, then rename to final
|
|
const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`);
|
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
await fs.promises.copyFile(path.join(sourceDir, 'index.js'), path.join(tmpDir, 'index.js'));
|
|
await fs.promises.copyFile(
|
|
path.join(sourceDir, 'package.json'),
|
|
path.join(tmpDir, 'package.json')
|
|
);
|
|
|
|
try {
|
|
await fs.promises.rename(tmpDir, finalDir);
|
|
} catch {
|
|
// finalDir appeared between our check and rename (another process won the race)
|
|
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
if (await hasValidServerCopy(finalDir)) {
|
|
logger.info(`Using stable MCP server copy at ${finalDir} (concurrent copy resolved)`);
|
|
return finalEntry;
|
|
}
|
|
// Neither our copy nor the winner's copy is valid — fallback
|
|
logger.warn(`Concurrent MCP server copy failed, using resourcesPath fallback`);
|
|
return fallbackEntry;
|
|
}
|
|
|
|
logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`);
|
|
emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...');
|
|
return finalEntry;
|
|
} catch (error) {
|
|
logger.warn(
|
|
`Failed to copy MCP server to stable path, using resourcesPath fallback: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
return fallbackEntry;
|
|
}
|
|
}
|
|
|
|
export async function resolveAgentTeamsMcpLaunchSpec(
|
|
options: McpLaunchSpecResolveOptions = {}
|
|
): Promise<McpLaunchSpec> {
|
|
const checked: string[] = [];
|
|
|
|
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
|
|
if (isPackagedApp()) {
|
|
const packagedEntry = await resolvePackagedServerEntry(options);
|
|
checked.push(packagedEntry);
|
|
if (await pathExists(packagedEntry)) {
|
|
if (shouldUsePackagedElectronNodeRuntime()) {
|
|
const electronProbe = await probePackagedElectronNodeRuntime(options);
|
|
if (electronProbe.ok) {
|
|
emitProgress(
|
|
options,
|
|
'electron-node-runtime-found',
|
|
'Using bundled Electron Node runtime...'
|
|
);
|
|
return buildPackagedElectronNodeLaunchSpec(packagedEntry);
|
|
}
|
|
logger.warn(
|
|
`Bundled Electron Node runtime is unavailable for Agent Teams MCP; falling back to Node.js runtime: ${stringifyError(
|
|
electronProbe.error
|
|
)}`
|
|
);
|
|
emitProgress(
|
|
options,
|
|
'electron-node-runtime-fallback',
|
|
'Bundled Electron Node runtime unavailable, resolving Node.js fallback...'
|
|
);
|
|
}
|
|
return {
|
|
command: await resolveNodePath(options),
|
|
args: [packagedEntry],
|
|
};
|
|
}
|
|
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
|
|
}
|
|
|
|
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
|
const sourceEntry = getSourceServerEntry();
|
|
emitProgress(options, 'source-entry', 'Checking MCP source entry...');
|
|
checked.push(sourceEntry);
|
|
if (await pathExists(sourceEntry)) {
|
|
emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...');
|
|
const tsxCli = await resolveWorkspaceTsxCli(checked);
|
|
if (tsxCli) {
|
|
return {
|
|
command: await resolveNodePath(options),
|
|
args: [tsxCli, sourceEntry],
|
|
};
|
|
}
|
|
}
|
|
|
|
// 3. Dev mode fallback — use built dist when source execution is unavailable
|
|
const builtEntry = getBuiltServerEntry();
|
|
emitProgress(options, 'built-entry', 'Checking built MCP server entry...');
|
|
checked.push(builtEntry);
|
|
if (await pathExists(builtEntry)) {
|
|
return {
|
|
command: await resolveNodePath(options),
|
|
args: [builtEntry],
|
|
};
|
|
}
|
|
|
|
throw new Error(
|
|
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
|
|
);
|
|
}
|
|
|
|
export class TeamMcpConfigBuilder {
|
|
async writeConfigFile(projectPath?: string, options?: WriteMcpConfigOptions): Promise<string>;
|
|
async writeConfigFile(projectPath?: string, mcpPolicy?: TeamMemberMcpPolicy): Promise<string>;
|
|
async writeConfigFile(
|
|
projectPath?: string,
|
|
optionsOrPolicy?: WriteMcpConfigOptions | TeamMemberMcpPolicy
|
|
): Promise<string> {
|
|
const launchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
|
const configDir = getMcpConfigsBasePath();
|
|
const configPath = path.join(
|
|
configDir,
|
|
`${MCP_CONFIG_PREFIX}${process.pid}-${Date.now()}-${randomUUID()}.json`
|
|
);
|
|
const options = isWriteMcpConfigOptions(optionsOrPolicy)
|
|
? optionsOrPolicy
|
|
: ({
|
|
mcpPolicy: optionsOrPolicy,
|
|
} satisfies WriteMcpConfigOptions);
|
|
const mcpPolicy = options.mcpPolicy;
|
|
const controlApiBaseUrl =
|
|
options.controlApiBaseUrl?.trim() || process.env[MCP_CONTROL_URL_ENV]?.trim() || '';
|
|
// Keep the team bootstrap config minimal: recent Claude sidechain runs can
|
|
// lose the agent-teams tool surface when we inline large user MCP bundles
|
|
// into the generated --mcp-config. User/project/local MCP remain loaded
|
|
// through Claude's native settings sources.
|
|
const generatedServers: Record<string, McpServerConfig> = Object.create(null);
|
|
generatedServers[MCP_SERVER_NAME] = {
|
|
command: launchSpec.command,
|
|
args: launchSpec.args,
|
|
enabled: true,
|
|
env: {
|
|
...launchSpec.env,
|
|
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
|
|
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
|
|
},
|
|
};
|
|
if (mcpPolicy?.mode === 'strictAllowlist') {
|
|
for (const [name, config] of Object.entries(
|
|
await this.readAllowlistedServers(projectPath, mcpPolicy)
|
|
)) {
|
|
generatedServers[name] = config;
|
|
}
|
|
}
|
|
|
|
await fs.promises.mkdir(configDir, { recursive: true });
|
|
await atomicWriteAsync(
|
|
configPath,
|
|
JSON.stringify(
|
|
{
|
|
mcpServers: generatedServers,
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
return configPath;
|
|
}
|
|
|
|
private async readAllowlistedServers(
|
|
projectPath: string | undefined,
|
|
policy: TeamMemberMcpPolicy
|
|
): Promise<Record<string, McpServerConfig>> {
|
|
const allowlist = new Set(
|
|
(policy.serverNames ?? [])
|
|
.map((name) => name.trim())
|
|
.filter(Boolean)
|
|
.map((name) => name.toLowerCase())
|
|
);
|
|
if (allowlist.size === 0) {
|
|
return {};
|
|
}
|
|
|
|
const scopes = resolveTeamMemberMcpScopes(policy);
|
|
const entries = await new McpConfigStateReader().readConfigured(projectPath);
|
|
const byScope = new Map<TeamMemberMcpScope, typeof entries>();
|
|
for (const scope of MCP_CONFIG_SCOPE_PRECEDENCE) {
|
|
byScope.set(scope, []);
|
|
}
|
|
for (const entry of entries) {
|
|
if (!scopes[entry.scope]) {
|
|
continue;
|
|
}
|
|
byScope.get(entry.scope)?.push(entry);
|
|
}
|
|
|
|
const selected: Record<string, McpServerConfig> = Object.create(null);
|
|
for (const scope of MCP_CONFIG_SCOPE_PRECEDENCE) {
|
|
for (const entry of byScope.get(scope) ?? []) {
|
|
if (entry.name.toLowerCase() === MCP_SERVER_NAME) {
|
|
continue;
|
|
}
|
|
if (allowlist.has(entry.name.toLowerCase())) {
|
|
selected[entry.name] = entry.config;
|
|
}
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
/** Delete a single MCP config file (best-effort). */
|
|
async removeConfigFile(configPath: string): Promise<void> {
|
|
for (let attempt = 0; attempt <= MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
try {
|
|
await fs.promises.unlink(configPath);
|
|
return;
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (err.code === 'ENOENT') {
|
|
return;
|
|
}
|
|
if (
|
|
shouldRetryMcpConfigRemoval(err) &&
|
|
attempt < MCP_CONFIG_REMOVE_RETRY_DELAYS_MS.length
|
|
) {
|
|
await waitForRetry(MCP_CONFIG_REMOVE_RETRY_DELAYS_MS[attempt]);
|
|
continue;
|
|
}
|
|
if (shouldRetryMcpConfigRemoval(err)) {
|
|
logger.debug(`Deferred MCP config cleanup for ${configPath}: ${err.message}`);
|
|
return;
|
|
}
|
|
logger.warn(`Failed to remove MCP config ${configPath}: ${err.message}`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Remove config files owned by current process (shutdown best-effort). */
|
|
async gcOwnConfigs(): Promise<void> {
|
|
const configDir = getMcpConfigsBasePath();
|
|
const ownPrefix = `${MCP_CONFIG_PREFIX}${process.pid}-`;
|
|
try {
|
|
const entries = await fs.promises.readdir(configDir);
|
|
await Promise.all(
|
|
entries
|
|
.filter((n) => n.startsWith(ownPrefix) && n.endsWith('.json'))
|
|
.map((n) => fs.promises.unlink(path.join(configDir, n)).catch(() => {}))
|
|
);
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (err.code !== 'ENOENT') {
|
|
logger.warn(`Failed to GC own MCP configs: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove stale config files older than maxAgeMs (startup GC, best-effort).
|
|
* Risk is reduced but not eliminated for multi-instance scenarios:
|
|
* respawnAfterAuthFailure() has its own recovery to regenerate deleted configs.
|
|
*/
|
|
async gcStaleConfigs(maxAgeMs = MCP_CONFIG_STALE_MAX_AGE_MS): Promise<void> {
|
|
const configDir = getMcpConfigsBasePath();
|
|
try {
|
|
const entries = await fs.promises.readdir(configDir);
|
|
await Promise.all(
|
|
entries
|
|
.filter((n) => n.startsWith(MCP_CONFIG_PREFIX) && n.endsWith('.json'))
|
|
.map(async (n) => {
|
|
const fullPath = path.join(configDir, n);
|
|
try {
|
|
const stat = await fs.promises.stat(fullPath);
|
|
if (Date.now() - stat.mtimeMs > maxAgeMs) {
|
|
await fs.promises.unlink(fullPath);
|
|
}
|
|
} catch {
|
|
/* ignore per-file errors */
|
|
}
|
|
})
|
|
);
|
|
} catch (error) {
|
|
const err = error as NodeJS.ErrnoException;
|
|
if (err.code !== 'ENOENT') {
|
|
logger.warn(`Failed to GC stale MCP configs: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|